---
title: Workflow State
description: How data flows between tasks, the ctx API, and the distinction between step outputs and shared workflow state.
---

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:

```tsx
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:

```tsx
// 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:

```tsx
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:

```tsx
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

| 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 |

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`.

### `ctx.latestArray(value, schema)`

Parses a value as a JSON array and validates each element against a Zod schema. Invalid items are silently dropped. This is useful when an agent returns a JSON string containing an array, and you want type-safe, validated elements:

```tsx
const items = ctx.latestArray(
  ctx.latest("findings", "scan")?.items,
  z.object({ severity: z.string(), message: z.string() }),
);

// items: Array<{ severity: string; message: string }>
// Malformed entries are filtered out automatically
```

If `value` is a string, it is JSON-parsed first. If it is already an array, each element is validated directly. Non-array values are wrapped in a single-element array before validation.

### Table Name Resolution

The `table` parameter in `ctx.output()`, `ctx.outputMaybe()`, `ctx.latest()`, and `ctx.iterationCount()` accepts three forms:

| Form | Example | Resolution |
| --- | --- | --- |
| String schema key | `"analysis"` | Looked up directly in the outputs snapshot |
| Zod schema | `outputs.analysis` | Resolved via the `createSmithers()` schema registry to its key name |
| Drizzle table | `analysisTable` | Resolved via `getTableName()` to the SQL table name |

The recommended form is the Zod schema from `outputs`, which provides type inference. String keys work when you need dynamic lookups.

### `ctx.auth`

The authentication context passed via `RunOptions.auth`. Returns `null` when no auth context was configured:

```tsx
export default smithers((ctx) => (
  <Workflow name="gated">
    <Task id="deploy" output={outputs.deploy} agent={deployer} skipIf={ctx.auth?.role !== "admin"}>
      {`Deploy as ${ctx.auth?.userId}`}
    </Task>
  </Workflow>
));
```

## Workflow Input

`ctx.input` holds the workflow's input, validated against its schema before execution begins:

```tsx
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.

### Input Payload Unwrapping

When input is stored in a table with only `runId` and `payload` columns (the default `_smithers_input` table), Smithers automatically unwraps the `payload` field. If `payload` is a JSON string, it is parsed. This means `ctx.input` always gives you the clean, deserialized input object -- never the raw database row with `runId` and `payload` wrappers.

## 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:

```tsx
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

```tsx
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.

| 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 |

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](/concepts/control-flow) -- The four primitives that determine execution order.
- [Data Model](/concepts/data-model) -- How Schema, Model, and metadata fit together at the persistence layer.
- [Suspend and Resume](/concepts/suspend-and-resume) -- How state survives crashes and approval gates.
