Skip to main content
import { Task } from "smithers-orchestrator";

type TaskProps = {
  id: string;
  output: z.ZodObject | Table | string;
  outputSchema?: z.ZodObject; // inferred when output is a Zod schema
  agent?: AgentLike | AgentLike[]; // array = [primary, ...fallbacks]
  fallbackAgent?: AgentLike;
  dependsOn?: string[];
  needs?: Record<string, string>;
  deps?: Record<string, OutputTarget>; // typed render-time upstream outputs
  fork?: string; // start from another task's final agent session snapshot
  allowTools?: string[]; // CLI-agent tool allowlist
  key?: string;
  skipIf?: boolean;
  needsApproval?: boolean; // pause for human before executing
  async?: boolean; // with needsApproval: let unrelated flow continue
  timeoutMs?: number;
  retries?: number; // default Infinity with exponential backoff
  noRetry?: boolean;
  retryPolicy?: { backoff?: "fixed" | "linear" | "exponential"; initialDelayMs?: number };
  continueOnFail?: boolean;
  cache?: { by?: (ctx) => unknown; version?: string; key?: string; ttlMs?: number; scope?: "run" | "workflow" | "global" };
  label?: string;
  meta?: Record<string, unknown>;
  scorers?: ScorersMap;
  memory?: {
    recall?: { namespace?: string; query?: string; topK?: number };
    remember?: { namespace?: string; key?: string };
    threadId?: string;
  };
  heartbeatTimeoutMs?: number; // fail if no heartbeat in window
  heartbeatTimeout?: number; // alias of heartbeatTimeoutMs
  hijack?: boolean; // request an immediate hijack handoff as soon as the task starts running
  onHijackExit?: "complete" | "reopen"; // what Smithers should do after a hijacked session exits
  children?:
    | string
    | Row
    | (() => Row | Promise<Row>)
    | ReactNode
    | ((deps) => Row | ReactNode);
};
output={outputs.someKey} is the canonical schema path. outputs comes from the same createSmithers(...) call as the workflow, and each value is the exact Zod schema object you registered. When a Task renders, Smithers uses that object as the output target, infers outputSchema, passes it to native structured-output agents, validates the returned row, and persists it to the matching output table. You only need outputSchema={...} when output is a custom Drizzle table or string key and Smithers cannot infer the Zod schema.
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";

const codeAgent = new Agent({
  model: anthropic("claude-fable-5"),
  instructions: "You are a senior software engineer.",
});

<Task id="analyze" output={outputs.analysis} agent={codeAgent}>
  {`Analyze: ${ctx.input.repoPath}`}
</Task>

<Task id="review" output={outputs.review} agent={reviewAgent} deps={{ analyze: outputs.analysis }}>
  {(deps) => `Review: ${deps.analyze.summary}`}
</Task>

Three task modes: agent, compute, static

The children shape decides what a Task does. No agent prop is needed for the compute and static modes.
  • 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 do real work here (run a test command, call an API, read a file). Whatever it returns is validated against output and persisted, exactly like an agent’s parsed JSON.
  • Static (no agent, children is a literal value): persists the value as-is.
// Compute task: an async function body runs real work, no agent involved.
<Task id="run-tests" output={outputs.testResult}>
  {async () => {
    const proc = Bun.spawn(["bun", "test"], { stdout: "pipe", stderr: "pipe" });
    const stdout = await new Response(proc.stdout).text();
    const code = await proc.exited;
    return { passed: code === 0, log: stdout.slice(-4000) };
  }}
</Task>

// Compute task reading an upstream output via deps; still just a function.
<Task id="count" output={outputs.count} deps={{ analyze: outputs.analysis }}>
  {async (deps) => ({ issues: deps.analyze.issues.length })}
</Task>

// Static task: a literal value, persisted as-is.
<Task id="config" output={outputs.config}>
  {{ region: "us-east-1", retries: 3 }}
</Task>
A compute or static Task is a first-class node: it persists, resumes, gates downstream deps, and works inside <Loop>, <Branch>, <Parallel>, and <Sequence> just like an agent Task. The only thing it cannot do is be a fork source (no agent session). Throwing inside a compute function fails the task and triggers its retry policy.

Dependencies: dependsOn, needs, deps

Three props express upstream dependencies. They 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 is the name that value is looked up under.
  • deps: Record<string, OutputTarget>: typed render-time outputs. The task gates on each dependency and passes the resolved rows to a children callback via {(deps) => ...}.
The detail that bites people: a deps key is treated as the upstream task’s id. deps={{ analyze: outputs.analysis }} depends on a task whose id is analyze. If your dep name differs from the upstream id, remap it with needs:
// Upstream task id is "parse-summary", but you want to call it `summary` locally.
<Task id="parse-summary" output={outputs.summary}>{/* ... */}</Task>

<Task id="report" output={outputs.report}
  needs={{ summary: "parse-summary" }}   // map the key to the real task id
  deps={{ summary: outputs.summary }}>
  {(deps) => deps.summary.text}
</Task>
Without the needs remap, deps={{ summary: ... }} depends on a node id summary that no task produces. The dependency can never resolve, and the run fails with DEPENDENCY_DEADLOCK naming the stuck task. (Previously this hung or finished while silently skipping the task.) needs alone works too when you don’t need the typed children callback.

Across a loop boundary

A task inside a <Loop> can depend on a task outside the loop. The upstream is resolved at its own iteration, not the loop’s, so deps/needs reach it from any iteration:
<Task id="config" output={outputs.config}>{/* runs once, outside the loop */}</Task>

<Loop id="work" until={done}>
  <Task id="step" output={outputs.step}
    needs={{ config: "config" }}
    deps={{ config: outputs.config }}>
    {(deps) => useRegion(deps.config.region)}
  </Task>
</Loop>

Fork

Every agent task produces a reusable session snapshot. Use fork to start a new task from any previous task’s 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 session context, then submits its own prompt into that copy.
  • B produces its own output and its own session snapshot. A is never mutated.
fork is immutable. It does not continue or mutate the source task; it copies the conversation into a fresh, independent session. Multiple tasks may fork the same source safely, and a forked task may itself be forked.
const PLAN = "plan" as const;
const IMPLEMENT = "implement" as const;
const VERIFY = "verify" as const;

<Task id={PLAN} agent={claude} output={outputs.plan}>
  Make a plan.
</Task>

<Task id={IMPLEMENT} agent={claude} fork={PLAN} output={outputs.patch}>
  Implement the plan.
</Task>

<Task id={VERIFY} agent={claude} fork={IMPLEMENT} output={outputs.result}>
  Run tests and fix failures.
</Task>
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 and they never affect each other:
<Task id="investigate" agent={claude} output={outputs.investigation}>
  Understand the bug and identify possible fixes.
</Task>

<Parallel>
  <Task id="minimal-fix" agent={claude} fork="investigate" output={outputs.patch}>
    Try the minimal fix.
  </Task>
  <Task id="refactor-fix" agent={claude} fork="investigate" output={outputs.patch}>
    Try the refactor fix.
  </Task>
</Parallel>
fork composes with dependsOn, needs, deps, Sequence, Parallel, Branch, and Loop. Inside a loop, fork resolves to the latest completed session snapshot for that task id; there is no iteration selector and no ambiguity.

Error cases

  • TASK_FORK_SOURCE_NOT_FOUND: fork points to a task id not present in the graph (including a source that exists only in an unselected <Branch>).
  • TASK_FORK_CYCLE: fork creates a cycle, directly or indirectly.
  • TASK_FORK_SESSION_UNAVAILABLE: the forking task is not an agent task, or the source completed but produced no usable session snapshot (e.g. a compute/static source, or a source that was skipped/cancelled).
  • TASK_FORK_SOURCE_NOT_COMPLETE: the source exists but has not completed; the forked task waits and does not run.

Notes

  • Three modes by children shape: agent (with agent), compute (function, no agent), static (value, no agent).
  • fork requires an agent task; the source must be an agent task with a session snapshot. Forking copies the conversation into a new session and never reuses a native session id.
  • When outputSchema is set, JSON is extracted from agent text; schema-validation retries don’t consume retries.
  • Auth errors short-circuit retries; non-idempotent tool reuse warns on the next attempt.