Every Smithers workflow receives a SmithersCtx object that provides type-safe access to the current run’s input data, all outputs produced so far, and loop iteration state. The context is the primary mechanism for making your workflow reactive — tasks can read outputs from earlier tasks and conditionally render new work.
Getting the Context
There are two ways to access context depending on your API choice.
Builder function parameter (both APIs):
const { Workflow, smithers } = createSmithers({
analyze: z.object({ summary: z.string() }),
});
export default smithers((ctx) => (
<Workflow name="example">
<Task id="analyze" output="analyze" agent={agent}>
{`Analyze: ${ctx.input.description}`}
</Task>
</Workflow>
));
useCtx() hook (inside components, with createSmithers):
const { Workflow, useCtx, smithers } = createSmithers({
analyze: z.object({ summary: z.string() }),
});
function AnalyzeStep() {
const ctx = useCtx();
return (
<Task id="analyze" output="analyze" agent={agent}>
{`Analyze: ${ctx.input.description}`}
</Task>
);
}
export default smithers(() => (
<Workflow name="example">
<AnalyzeStep />
</Workflow>
));
SmithersCtx Interface
interface SmithersCtx<Schema> {
runId: string;
iteration: number;
iterations?: Record<string, number>;
input: Record<string, unknown>;
outputs: OutputAccessor<Schema>;
output<K extends keyof Schema & string>(
table: K,
key: { nodeId: string; iteration?: number },
): InferOutputEntry<Schema[K]>;
outputMaybe<K extends keyof Schema & string>(
table: K,
key: { nodeId: string; iteration?: number },
): InferOutputEntry<Schema[K]> | undefined;
latest<K extends keyof Schema & string>(
table: K,
nodeId: string,
): InferOutputEntry<Schema[K]> | undefined;
latestArray(value: unknown, schema: ZodType): any[];
iterationCount(table: any, nodeId: string): number;
}
Properties
runId
The unique identifier for the current workflow run. This is either auto-generated or provided via runWorkflow(workflow, { runId: "custom-id" }).
iteration
The current Ralph loop iteration. For tasks not inside a <Ralph>, this is always 0. Inside a Ralph loop, it reflects the iteration number of the enclosing loop (starting from 0).
iterations
A record mapping Ralph IDs to their current iteration numbers. Useful when multiple Ralph loops exist in the same workflow:
<Ralph id="outer" until={outerDone}>
<Ralph id="inner" until={innerDone}>
{/* ctx.iterations is { outer: 2, inner: 1 } */}
</Ralph>
</Ralph>
Note: nested Ralph is not currently supported, but the iterations map is still populated for all Ralph nodes in the tree.
The input object for the current run. In the manual API, this is the row from schema.input. In the schema-driven API, it is the decoded JSON payload that was passed to runWorkflow(workflow, { input: { ... } }).
<Task id="analyze" output="analyze" agent={agent}>
{`Analyze: ${ctx.input.description}`}
</Task>
outputs
An OutputAccessor that serves double duty — it is both callable as a function and indexable as an object.
As a function — returns all rows for a table:
const allAnalyzeRows = ctx.outputs("analyze"); // Row[]
As a property — returns all rows by table name:
const allAnalyzeRows = ctx.outputs.analyze; // Row[]
Methods
output(table, key)
Returns the output row matching the given nodeId and optional iteration. Throws an error if no matching row exists.
const result = ctx.output("analyze", { nodeId: "analyze" });
// result.summary is guaranteed to be defined
When iteration is not specified, it defaults to the current ctx.iteration. For non-loop tasks, this means iteration 0.
Use this when you know the output must exist — typically when reading from a task that is sequenced before the current one.
outputMaybe(table, key)
Same as output() but returns undefined instead of throwing when no matching row exists.
const analysis = ctx.outputMaybe("analyze", { nodeId: "analyze" });
return (
<Workflow name="conditional">
<Sequence>
<Task id="analyze" output="analyze" agent={agent}>
{prompt}
</Task>
{analysis ? (
<Task id="review" output="review" agent={reviewAgent}>
{`Review: ${analysis.summary}`}
</Task>
) : null}
</Sequence>
</Workflow>
);
This is the most common pattern for conditional rendering. On the first render cycle, analysis is undefined so the review task is not mounted. After the analyze task completes and the tree re-renders, analysis is populated and the review task mounts.
latest(table, nodeId)
Returns the output row with the highest iteration number for a given nodeId. This is particularly useful inside or after Ralph loops where you want the most recent result regardless of which iteration produced it.
Parameters:
| Parameter | Type | Description |
|---|
table | Table | string | The output table to search. When using the schema-driven API (createSmithers with Zod schemas), pass the schema key name as a string — i.e. the key you used in the object passed to createSmithers(), not the underlying Drizzle/SQLite table name. When using the db-based API, pass the Drizzle table object from your schema (e.g. schema.draft). |
nodeId | string | The id prop of the <Task> whose output you want to retrieve. |
Schema-driven API (string keys):
const { Workflow, smithers } = createSmithers({
code: z.object({ source: z.string() }),
review: z.object({ approved: z.boolean(), feedback: z.string() }),
});
export default smithers((ctx) => {
// Pass the schema key "review", NOT the SQLite table name "review"
// (they may differ -- e.g. a key "finalOutput" maps to table "final_output")
const latestReview = ctx.latest("review", "review-task");
// ...
});
Db-based API (Drizzle table objects):
const latestDraft = ctx.latest(schema.draft, "write");
The lookup compares all rows in the table matching the nodeId and picks the one with the largest iteration value. Returns undefined if no rows match.
A common mistake is passing the Drizzle table name (the snake_case SQLite name like "final_output") instead of the schema key (the camelCase key like "finalOutput" that you defined in createSmithers()). The table parameter in the schema-driven API must match the key in your schema object.
latestArray(value, schema)
A utility for safely parsing array fields stored as JSON text in SQLite. Takes a raw value (typically from a JSON column) and a Zod schema, returning an array of validated items. Invalid items are silently dropped.
const draft = ctx.latest("draft", "write");
const sources = ctx.latestArray(draft?.sources, z.string());
// sources: string[] -- only items that pass z.string() validation
Handles these input shapes:
null or undefined returns []
- A JSON string is parsed, then each element is validated
- An array is validated element-by-element
- A non-array value is wrapped in
[value] and validated
iterationCount(table, nodeId)
Returns the number of distinct iterations that have produced output for a given nodeId. Useful for tracking Ralph loop progress.
Parameters:
| Parameter | Type | Description |
|---|
table | Table | string | The output table to search. Same rules as latest() — use the schema key string with createSmithers, or a Drizzle table object with the db-based API. |
nodeId | string | The id prop of the <Task> whose iterations you want to count. |
const draftsCompleted = ctx.iterationCount("draft", "write");
// e.g., 3 if iterations 0, 1, 2 have output rows
Row Resolution Logic
When output() or outputMaybe() looks up a row, the matching logic works as follows:
- Find all rows in the table where
row.nodeId === key.nodeId.
- If the table has an
iteration column, also match row.iteration === (key.iteration ?? ctx.iteration).
- Return the first match, or
undefined/throw depending on the method.
This means that inside a Ralph loop, calling ctx.output(table, { nodeId: "write" }) without specifying iteration automatically matches the row for the current iteration.
Patterns
Chaining Sequential Tasks
export default smithers((ctx) => {
const analysis = ctx.outputMaybe("analyze", { nodeId: "analyze" });
const review = ctx.outputMaybe("review", { nodeId: "review" });
return (
<Workflow name="pipeline">
<Sequence>
<Task id="analyze" output="analyze" agent={analyzeAgent}>
{`Analyze: ${ctx.input.description}`}
</Task>
{analysis ? (
<Task id="review" output="review" agent={reviewAgent}>
{`Review this analysis: ${analysis.summary}`}
</Task>
) : null}
{review ? (
<Task id="publish" output="output">
{{ title: ctx.input.description, approved: review.approved }}
</Task>
) : null}
</Sequence>
</Workflow>
);
});
Reading Ralph Loop History
export default smithers((ctx) => {
const latestDraft = ctx.latest("draft", "write");
const draftCount = ctx.iterationCount("draft", "write");
const converged = latestDraft?.score > 0.9 || draftCount >= 5;
return (
<Workflow name="iterative">
<Ralph until={converged} maxIterations={10} onMaxReached="return-last">
<Task id="write" output="draft" agent={writerAgent}>
{latestDraft
? `Improve this draft (score: ${latestDraft.score}): ${latestDraft.text}`
: `Write a first draft about: ${ctx.input.topic}`}
</Task>
</Ralph>
</Workflow>
);
});
Ralph Loop: Iterative Refinement with ctx.latest()
The most common Ralph pattern is iterative refinement — a task produces output, a second task reviews it, and the loop continues until the review passes. The key to this pattern is that ctx.latest() always returns the most recent iteration’s output, and the workflow tree is re-rendered on every iteration.
Here is how the re-render cycle works:
- First render:
ctx.latest("review", "review") returns undefined because no review output exists yet. The writer task sees no feedback and produces an initial draft.
- Task execution: The writer and reviewer tasks run in sequence within the Ralph body. Their outputs are persisted with the current iteration number.
- Re-render: After the iteration completes, the renderer re-evaluates the entire workflow tree with updated context. Now
ctx.latest("review", "review") returns the review from iteration 0.
until check: The Ralph until prop is re-evaluated. If latestReview?.approved === true, the loop exits. Otherwise, the next iteration begins.
- Next iteration: The writer task now sees the reviewer’s feedback from the previous iteration and can refine its output.
export default smithers((ctx) => {
// These calls run on EVERY re-render.
// On the first render, both return undefined.
// On subsequent renders, they return the output from the previous iteration.
const latestCode = ctx.latest("code", "write");
const latestReview = ctx.latest("review", "review");
return (
<Workflow name="write-review-loop">
<Ralph
until={latestReview?.approved === true}
maxIterations={5}
onMaxReached="return-last"
>
<Sequence>
<Task id="write" output="code" agent={coder}>
{latestReview
? `Revise the code based on this feedback: ${latestReview.feedback}`
: `Write a first draft: ${ctx.input.description}`}
</Task>
<Task id="review" output="review" agent={reviewer}>
{`Review this code:\n\n${latestCode?.source ?? "// pending"}`}
</Task>
</Sequence>
</Ralph>
</Workflow>
);
});
The workflow builder function (ctx) => (...) is called fresh on every render frame. This means ctx.latest() and ctx.outputMaybe() calls at the top of the function body always reflect the most recently persisted outputs. You do not need to manage state yourself — the renderer handles it.
Accessing All Outputs from a Table
const allReviews = ctx.outputs("review");
const approvedCount = allReviews.filter((r) => r.approved).length;