ctx. That’s the whole model.
Let’s see what that looks like.
How Data Flows Between Tasks
Forget function pipelines where return values pass hand-to-hand. In Smithers, tasks communicate through persisted outputs and re-renders:analysis is undefined, so only analyze mounts. It runs, its output is persisted to SQLite, and then the tree re-renders. Now analysis has a value, fix mounts, and the workflow moves forward.
The flow is:
- First render:
analysisisundefined, onlyanalyzeis mounted analyzeruns, output is persisted to SQLite- Second render:
analysishas a value,fixis mounted fixruns usinganalysisdata in its prompt
The Context API
Thectx object gives you three ways to read outputs. Each exists for a reason.
ctx.output(schema, { nodeId })
Returns the output or throws if it doesn’t exist yet. Use it when you know the upstream task has completed — inside a <Sequence>, for instance, where ordering is guaranteed:
<Sequence> guarantees analyze finishes first, so ctx.output will always find data.
ctx.outputMaybe(schema, { nodeId })
Returns the output or undefined. This is the one you reach for when you’re conditionally rendering — when the answer to “has this task run yet?” controls what mounts next:
ctx.latest(schema, nodeId)
Returns the most recent output across all loop iterations. Without this, every iteration would see only its own output, and you’d have no way to feed one iteration’s result into the next:
When to Use Each
| Method | Returns | Throws? | Use case |
|---|---|---|---|
ctx.output() | T | Yes, if missing | Inside sequential blocks where the upstream task is guaranteed to exist |
ctx.outputMaybe() | T | undefined | No | Conditional rendering, gating downstream tasks |
ctx.latest() | T | undefined | No | Inside loops, to read the most recent iteration’s output |
output. If you’re branching on whether it exists, use outputMaybe. If you’re looping, use latest.
Workflow Input
ctx.input holds the workflow’s input, validated against its schema before execution begins:
ctx.input is the same value that started the run.
Step Outputs vs Shared State
Here’s the key insight: in Smithers, outputs are the state. There is no separate “workflow state” object that tasks read from and write to. The rendered JSX tree plus the persisted outputs together are the workflow state.In Smithers: Outputs are the state
Each task produces a typed, validated output. That output is the state. Think of it like a database where every task owns its own table, rather than a whiteboard where everyone scribbles in the same corner. This has important consequences:- No race conditions — Tasks don’t compete to update a shared store. Each task writes to its own output table.
- Natural type safety — Each output has its own Zod schema. There’s no untyped global bag.
- Resumability — Because each output is persisted independently, crash recovery is straightforward.
Data sharing across steps
“But what if two tasks need the same data?” They both read from the same upstream output. No copying, no shared store, no coordination:fix and report read from the same analysis output. No shared mutable state is needed.
Iteration State
Inside a<Loop>, each iteration produces separate output rows keyed by (runId, nodeId, iteration). This means:
- Iteration 0’s review and iteration 1’s review are stored as separate rows
ctx.latest()finds the highest iteration numberctx.iterationgives you the current iteration (0-indexed)ctx.iterationCount(schema, nodeId)tells you how many iterations have completed
ctx.latest() is doing the heavy lifting. On iteration 0, latestReview is undefined, so the writer gets the original topic. On iteration 1, latestReview has feedback from the first review, so the writer revises. Each iteration builds on the last, and you didn’t have to manage any of that bookkeeping yourself.
Persistence
All task outputs are persisted to SQLite immediately on completion. This is what makes workflows durable — not your code, not a try/catch, just the fact that every output hits disk before the next task starts.| What | Where | Keyed by |
|---|---|---|
| Workflow input | _smithers_runs | runId |
| Task output | User-defined table | (runId, nodeId) or (runId, nodeId, iteration) |
| Execution metadata | _smithers_nodes, _smithers_attempts | Internal keys |
The Re-render Cycle
Here’s where it all comes together. Smithers re-renders the JSX tree after each task completes. This is how data-dependent control flow works without imperativeif statements or state machines:
- Render 1:
ctx.outputMaybe("analysis", ...)returnsundefined— onlyanalyzeis mounted analyzecompletes — output persisted- Render 2:
ctx.outputMaybe("analysis", ...)returns the analysis —fixis mounted fixcompletes — output persisted- Render 3: all tasks complete — workflow finishes
Next Steps
- Control Flow — The four primitives that determine execution order.
- Data Model — How Schema, Model, and metadata fit together at the persistence layer.
- Suspend and Resume — How state survives crashes and approval gates.