Skip to main content
Most workflow engines give you a shared state bag. Every task reads from it, writes to it, and hopes nobody else clobbered their key in the meantime. You’ve seen this movie before — it ends with race conditions and debugging sessions at 2 AM. Smithers doesn’t have shared mutable state. There is no global bag. Each task writes one typed output to SQLite, and downstream tasks read those outputs through 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:
export default smithers((ctx) => {
  const analysis = ctx.outputMaybe(outputs.analysis, { nodeId: "analyze" });

  return (
    <Workflow name="pipeline">
      <Task id="analyze" output={outputs.analysis} agent={analyst}>
        {`Analyze ${ctx.input.repo}`}
      </Task>

      {analysis ? (
        <Task id="fix" output={outputs.fix} agent={coder}>
          {`Fix these issues: ${analysis.issues.join(", ")}`}
        </Task>
      ) : null}
    </Workflow>
  );
});
Read that again slowly. On the first render, 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:
  1. First render: analysis is undefined, only analyze is mounted
  2. analyze runs, output is persisted to SQLite
  3. Second render: analysis has a value, fix is mounted
  4. fix runs using analysis data in its prompt
You might be wondering: “Why not just pass the return value directly?” Because persisted outputs buy you something return values can’t — durability. If the process crashes between step 1 and step 3, the output is already in SQLite. On restart, the second render picks up right where it left off.

The Context API

The ctx 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:
// Safe — "analyze" always completes before "report" in a Sequence
<Sequence>
  <Task id="analyze" output={outputs.analysis} agent={analyst}>...</Task>
  <Task id="report" output={outputs.report}>
    {{ summary: ctx.output(outputs.analysis, { nodeId: "analyze" }).summary }}
  </Task>
</Sequence>
No uncertainty here. The <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:
const analysis = ctx.outputMaybe(outputs.analysis, { nodeId: "analyze" });

// Only mount "fix" if "analyze" has produced output
{analysis ? (
  <Task id="fix" output={outputs.fix} agent={coder}>
    {`Fix: ${analysis.issues.join(", ")}`}
  </Task>
) : null}

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:
const latestReview = ctx.latest("review", "review");

<Loop until={latestReview?.approved === true} maxIterations={5}>
  <Task id="review" output={outputs.review} agent={reviewer}>
    {`Review the code. Previous feedback: ${latestReview?.feedback ?? "none"}`}
  </Task>
</Loop>

When to Use Each

MethodReturnsThrows?Use case
ctx.output()TYes, if missingInside sequential blocks where the upstream task is guaranteed to exist
ctx.outputMaybe()T | undefinedNoConditional rendering, gating downstream tasks
ctx.latest()T | undefinedNoInside loops, to read the most recent iteration’s output
The pattern: if you’re certain the data exists, use 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:
export default smithers((ctx) => (
  <Workflow name="deploy">
    <Task id="build" output={outputs.build}>
      {{ target: ctx.input.environment }}
    </Task>
  </Workflow>
));
Once a run starts, the input is immutable and persisted. Passing different input on resume is an error. This isn’t a limitation — it’s a guarantee. You can always trust that 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:
const analysis = ctx.outputMaybe(outputs.analysis, { nodeId: "analyze" });

<Parallel>
  <Task id="fix" output={outputs.fix} agent={coder}>
    {`Fix: ${analysis?.issues.join(", ")}`}
  </Task>
  <Task id="report" output={outputs.report} agent={writer}>
    {`Summarize: ${analysis?.summary}`}
  </Task>
</Parallel>
Both 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 number
  • ctx.iteration gives you the current iteration (0-indexed)
  • ctx.iterationCount(schema, nodeId) tells you how many iterations have completed
const latestDraft = ctx.latest("draft", "write");
const latestReview = ctx.latest("review", "review");

<Loop until={latestReview?.approved} maxIterations={5}>
  <Sequence>
    <Task id="write" output={outputs.draft} agent={writer}>
      {latestReview
        ? `Revise based on feedback: ${latestReview.feedback}`
        : `Write about: ${ctx.input.topic}`}
    </Task>
    <Task id="review" output={outputs.review} agent={reviewer}>
      {`Review: ${latestDraft?.text}`}
    </Task>
  </Sequence>
</Loop>
Notice how 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.
WhatWhereKeyed by
Workflow input_smithers_runsrunId
Task outputUser-defined table(runId, nodeId) or (runId, nodeId, iteration)
Execution metadata_smithers_nodes, _smithers_attemptsInternal keys
You don’t need to manage persistence yourself. Smithers handles it as part of the execution loop.

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 imperative if statements or state machines:
  1. Render 1: ctx.outputMaybe("analysis", ...) returns undefined — only analyze is mounted
  2. analyze completes — output persisted
  3. Render 2: ctx.outputMaybe("analysis", ...) returns the analysis — fix is mounted
  4. fix completes — output persisted
  5. Render 3: all tasks complete — workflow finishes
This cycle is automatic. You write declarative JSX; the render loop drives execution forward. The tree is a function of the persisted outputs, and the persisted outputs are a function of which tasks have run. One feeds the other until there’s nothing left to do.

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.