The Core Loop
Every workflow run follows the same four-phase cycle, repeated until no runnable nodes remain:Phase 1: Render
Smithers calls your builder function with the currentSmithersCtx, which contains the run’s input and all outputs produced so far. The JSX you return is rendered through a custom React reconciler — not React DOM — producing an in-memory HostElement tree.
The extractFromHost function then walks this tree depth-first, left-to-right and produces two outputs:
- TaskDescriptor[] — one entry per
<Task>node with itsnodeId,ordinal,iteration, output table, agent, prompt, and all configuration props (skipIf,needsApproval,retries,timeoutMs,continueOnFail). - XmlNode — a serializable XML snapshot of the full tree, stored in the
_smithers_framestable for debugging and inspection.
ctx.outputs and ctx.input but cannot mutate anything.
Phase 2: Schedule
The engine evaluates the state of every extracted task by checking:- Is the task’s
skipIfprop true? Mark asskipped. - Does it need approval? Check
_smithers_approvals. If not yet approved, emitApprovalRequestedand mark aswaiting-approval. - Is there an in-progress attempt? Mark as
in-progress. - Does a valid output row already exist in the output table for
(runId, nodeId, iteration)? Mark asfinished. - Is this a Ralph child whose loop is done? Mark as
skipped. - Has it exhausted its retries? Mark as
failed. - Otherwise: mark as
pending.
- Sequence: only the first non-terminal child is runnable.
- Parallel: all non-terminal children are runnable simultaneously.
- Ralph: children run as a group; when all finish, the loop may advance to the next iteration.
- Workflow: behaves like an implicit sequence at the root.
maxConcurrency (default 4) and per-<Parallel> maxConcurrency cap how many tasks execute at once, accounting for already in-progress tasks.
Phase 3: Execute
Runnable tasks execute in parallel up to the concurrency limit. For each task:- A new attempt row is inserted into
_smithers_attemptswith statein-progress. - The node row in
_smithers_nodesis updated toin-progress. - A
NodeStartedevent is emitted. - Agent tasks:
agent.generate()is called with the prompt. The response is parsed for JSON output (structured output, raw JSON, code-fenced JSON, or extracted via balanced-brace matching). If extraction fails, a follow-up prompt requests just the JSON. - Static tasks: the static payload from the JSX children is used directly.
- Output validation is performed against the Drizzle table schema.
- If schema validation fails for agent tasks, up to 2 schema-retry prompts are sent with the Zod error details.
- The validated payload is upserted into the output table.
Phase 4: Persist
After execution:- The attempt row is updated to
finished(orfailedon error). - The node row is updated accordingly.
NodeFinishedorNodeFailedevents are emitted.- If caching is enabled and this was not a cache hit, the result is written to
_smithers_cache. - The JJ pointer (version control change ID) is captured if available.
ctx, which may cause new tasks to mount, Ralph loops to advance, or Branch conditions to change.
Node States
Every task node transitions through these states:| State | Meaning |
|---|---|
pending | Extracted from the tree but not yet started. |
waiting-approval | The needsApproval prop is set and no approval decision exists yet. |
in-progress | An attempt is currently executing. |
finished | A valid output row exists in the output table. |
failed | All attempts have failed (retries exhausted) or approval was denied. |
cancelled | The node was unmounted between render cycles while in-progress, or the run was aborted. |
skipped | The skipIf prop evaluated to true, or a Branch was not taken, or a Ralph loop completed. |
_smithers_nodes before the engine proceeds.
Deterministic Ordering
Task ordering is deterministic and derived from the JSX tree structure:- Ordinals: Each
<Task>receives an ordinal assigned during depth-first, left-to-right extraction. The first task encountered gets ordinal 0, the next gets 1, and so on. - Sequence gating: Within a
<Sequence>(or<Workflow>, which is an implicit sequence), tasks execute in order. The second task does not become runnable until the first reaches a terminal state (finished,skipped, orfailedwithcontinueOnFail). - Parallel concurrency: Within a
<Parallel>, all children are eligible to run simultaneously, subject to concurrency limits. - Stable identity: Tasks are identified by their
idprop, not their position. Moving a<Task id="analyze">within the tree does not create a new task — the engine matches on(runId, nodeId).
Re-Render Cycle
The re-render cycle is what makes Smithers workflows reactive:analysis is undefined, so only the “analyze” task is mounted. After it executes and persists its output, the next render cycle sees analysis as a real row, causing the “review” task to mount and become runnable.
This pattern extends to any conditional rendering: Ralph loops that check convergence, Branch nodes that inspect previous outputs, and dynamic task lists generated from arrays.
Retries
When a task fails, the engine checks if the number of failed attempts is less thanretries + 1. If so, the node remains pending and will be picked up on the next scheduling pass. Each retry creates a new attempt row in _smithers_attempts — previous attempts are never overwritten.
A NodeRetrying event is emitted between the failure and the next attempt. The latest attempt always determines the node’s current state.
Run Termination
The loop terminates when one of these conditions is met:| Condition | Run Status |
|---|---|
| No runnable tasks and no pending work remain | finished |
A task failed without continueOnFail and no runnable work remains | failed |
| Tasks are waiting for approval and no other work is runnable | waiting-approval |
A Ralph loop exceeded maxIterations with onMaxReached: "fail" | failed |
The AbortSignal was triggered | cancelled |
waiting-approval, the run can be resumed after an approval or denial decision is made via the CLI.
Stale Attempt Recovery
If a run is interrupted (process crash, timeout), in-progress attempts older than 15 minutes are automatically cancelled on resume. This prevents zombie tasks from blocking forward progress. The nodes are reset topending so they can be retried.