output={outputs.someKey} is canonical: outputs comes from the workflow’s
createSmithers(...) call, each value the exact registered Zod schema.
Smithers infers outputSchema from it, passes that to native
structured-output agents, validates the returned row, and persists to the
matching table. Set outputSchema={...} only for a custom Drizzle table or
string key it can’t infer a schema from.
heartbeatTimeoutMs is opt-in and does not change the global timeout. While
an agent tool is executing, Smithers emits fenced periodic heartbeats until
that tool finishes; streamed stdout/stderr and a live owned CLI subprocess also
count as activity. A stalled agent with no stream, tool, subprocess, or explicit
runtime.heartbeat() still fails when the window expires. Process-exit and
task-completion cleanup remove liveness before a later timer tick, so stale
activity cannot keep a dead attempt alive.
A single tool call holds the task alive for at most twelve heartbeat windows.
An adapter that reports a tool start and then wedges emits nothing further, so
that lease expires and the attempt fails as a hung agent rather than running
forever. Raise heartbeatTimeoutMs when a node runs tools that legitimately
take longer than the lease allows.
Don’t name an output field iteration, runId, or nodeId. Smithers persists every output alongside those internal columns, so declaring one collides and the workflow fails to load (bunx smthrs graph errors before running). Loop counters hit this most: use round, pass, or attempt instead.
Three task modes: agent, compute, static
children’s shape decides what a Task does; compute and static modes need no
agent prop.
- Agent (
agent={...}, children is a prompt string or (deps) => string):
runs the agent, parses its output against the schema, persists.
- Compute (no
agent, children is a function returning a value): runs
your code (the function may be async and is awaited, so it can run a
test, call an API, read a file). The return value validates against
output and persists, like an agent’s parsed JSON.
- Static (no
agent, children is a literal value): persists the value as-is.
A declared async deps callback runs once inside the task attempt, after its
dependencies resolve, and the task cannot complete until its promise settles.
Synchronous deps callbacks retain their historical render-time evaluation
ordering.
A compute or static Task is a first-class node: it persists, resumes, gates
downstream deps, and works inside <Loop>, <Branch>, <Parallel>, and
<Sequence> like an agent Task, except it can’t be a fork source (no agent
session). Throwing in a compute function fails the task and triggers its retry
policy.
Repair after terminal failure
repair declares one bounded agent task that fixes a failed task’s
precondition. Smithers first exhausts the original task’s retryPolicy and
agent fallback chain. If the failure is terminal, it runs the repair in the
same workspace, persists the repair’s Zod-validated output, then runs the
original task one more time from the first agent in its chain.
The repair prompt always receives a structured context with runId,
nodeId, iteration, the terminal error, every durable attempt summary,
the original task kind, label, prompt or static payload, dependency ids, and
the resolved workflow input and dependency outputs, and the active workspace
path and branch. Attempt summaries include state,
timestamps, error payload, and jjPointer. The repair agent operates in that
same workspace, so filesystem changes are visible to the final original task
attempt.
Repair is a task, not a replacement result and not an automatic agent
escalation. Use fallbackAgent, an agent array, or fallbackAgents() to try a
different agent for the same work. Use repair when another task must change
the state that made the original work impossible.
The repair has a finite attempt budget. It runs once by default, or at most
retries + 1 times when configured. A repair that exhausts that budget fails
with TASK_REPAIR_FAILED; it never repairs itself, and the original task can
enter repair only once. Repair attempts and their output are ordinary durable
rows, so resuming after a process crash continues an interrupted repair rather
than starting a second recovery round.
repair and continueOnFail are mutually exclusive. continueOnFail already
declares that the failure is handled and downstream work may proceed. Combining
it with repair is rejected during graph extraction so there is no ambiguity
about whether a tolerated failure should trigger recovery.
External side effects
Mark a compute Task when its callback changes state outside the run database
and the git/jj-tracked worktree.
sideEffect accepts true or an object. true means
{ idempotent: false }. The engine writes one synthetic effect record for
each task attempt before calling the task. Success records succeeded; a
throw or interrupted attempt records unknown.
Use the object form to register compensation. The revert callback receives
outputRow, effectStatus, runId, nodeId, iteration, and attempt.
It must be idempotent. Check whether the external object exists before
removing it, especially when effectStatus is unknown.
Tool-level marking remains more precise when an agent or compute Task calls a
custom defineTool. Git commits, branch changes, worktree writes, and
git push are exempt because time travel already owns git state.
Dependencies: dependsOn, needs, deps
dependsOn, needs, and deps compose:
dependsOn: string[]: ordering only: the task waits until every listed task id is terminal; no values are passed in.
needs: Record<string, string>: named dependencies: each value is an upstream task id, each key the name it’s looked up under.
deps: Record<string, OutputTarget>: typed render-time outputs: the task gates on each dependency and passes resolved rows to a children callback via {(deps) => ...}.
depsOptional: boolean: tolerate unresolved deps: a task with deps normally defers until every dependency has an output row; depsOptional renders as soon as it can, dropping any dependency with no row (e.g. an upstream continueOnFail failure). Read defensively (deps.summary?.text): an unresolved dep is missing, not present-but-undefined.
A deps key is the upstream task’s id. deps={{ analyze: outputs.analysis }} depends on the task id analyze; if yours differs, remap it with needs:
Without the remap, deps={{ summary: ... }} depends on a node id summary no task produces: it never resolves, and the run fails with DEPENDENCY_DEADLOCK naming the stuck task (previously it hung or silently skipped it). needs alone works too, when you don’t need the typed children callback.
Preview renders always show the task. bunx smthrs graph mounts a task whose deps have not resolved yet, substituting a pending placeholder per dep key (printed as <pending:key> when a prompt template reads it), so a deps task is never silently dropped from the rendered graph. Placeholders exist only in the preview frame; at run time the task still defers until its upstream produces output, and a dependency that can never resolve fails the run with DEPENDENCY_DEADLOCK.
Across a loop boundary
A task inside a <Loop> can depend on a task outside it: the upstream resolves at its own iteration, not the loop’s, so deps/needs reach it from any iteration:
Fork
Checkpoint-aware and conversation-recording agent tasks produce reusable state; use fork to start a new task from a compatible previous task context.
<Task id={B} fork={A}> means:
B depends on A and cannot run until A has completed.
B starts from a copy of A’s final agent checkpoint when the target declares an exact codec/version/"fork" match in checkpointCapabilities. Otherwise Smithers falls back only when that same source attempt recorded forkable conversation messages.
B produces its own output and independent continuation state when its agent supports it.
fork never mutates or continues the source task; multiple tasks may fork the same source safely, and a forked task may itself be forked.
VERIFY forks IMPLEMENT, which forked PLAN, so VERIFY sees the whole plan → implement conversation.
Parallel branches: fork the same source from sibling tasks; each gets its own copy, unaffected by the others:
fork composes with dependsOn, needs, deps, Sequence, Parallel, Branch, and Loop. Inside a loop, it resolves to the latest completed session snapshot for that task id: no iteration selector, no ambiguity.
Error cases
TASK_FORK_SOURCE_NOT_FOUND: fork points to a task id absent from the graph (including one that exists only in an unselected <Branch>).
TASK_FORK_CYCLE: fork creates a cycle, directly or indirectly.
TASK_FORK_SESSION_UNAVAILABLE: the forking task isn’t an agent task, or its source completed with neither a checkpoint nor forkable conversation messages (e.g. a compute/static source, or one skipped/cancelled).
TASK_FORK_CHECKPOINT_INCOMPATIBLE: the source has only a native checkpoint and the target agent does not support its codec, version, or isolated-fork mode.
TASK_FORK_SOURCE_NOT_COMPLETE: the source exists but hasn’t completed; the forked task waits rather than running.
Notes
memory overrides the nearest <Memory> provider for this task. Recall and primer failures log a warning and run the original prompt.
- Native checkpoints are supplied as the discriminated pair
{ resumeCheckpoint, checkpointMode: "fork" }, never with resumeSession; adapters must create an isolated branch and never mutate the source. CLI session ids are never reused for forks. An incompatible native checkpoint falls back only to conversation messages recorded by the same source attempt; checkpoint-only sources fail with TASK_FORK_CHECKPOINT_INCOMPATIBLE.
- When
outputSchema is set, JSON is extracted from agent text. Missing or invalid JSON triggers up to maxSchemaRetries correction calls before the attempt fails; corrections resume the agent’s own CLI session (claude --resume, codex exec resume) when a session id was captured, so the fix runs with full task context instead of a fresh session. Correction calls don’t consume retries.
- Auth errors short-circuit retries; non-idempotent tool reuse warns on the next attempt.