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

Props

PropTypeDefaultDescription
idstringauto-generatedLoop identifier. Auto-generated from tree position if omitted.
untilboolean(required)Stop condition. Re-evaluated each iteration. Loop exits when true.
maxIterationsnumber5Maximum iterations. Loop stops regardless of until.
onMaxReached"fail" | "return-last""return-last"Behavior at limit. "fail": workflow fails. "return-last": keep final output and continue.
skipIfbooleanfalseSkip the loop entirely. Returns null.
childrenReactNodeundefinedTasks and control-flow components to execute each iteration.

Basic usage

<Workflow name="review-loop">
  <Loop
    until={ctx.outputMaybe(outputs.review, { nodeId: "review" })?.approved === true}
    maxIterations={3}
    onMaxReached="return-last"
  >
    <Task id="review" output={outputs.review} agent={reviewAgent}>
      Review the code and decide whether to approve.
    </Task>
  </Loop>
</Workflow>

Iteration state

Each iteration increments an internal counter exposed on the context:
  • ctx.iteration — current iteration number (0-indexed).
  • ctx.iterations — map of loop ids to current iteration numbers.
Tasks inside <Loop> receive the iteration number in their descriptor. Custom Drizzle tables must include iteration in the primary key. createSmithers(...) adds this automatically for schema-driven outputs.
const reviewTable = sqliteTable(
  "review",
  {
    runId: text("run_id").notNull(),
    nodeId: text("node_id").notNull(),
    iteration: integer("iteration").notNull().default(0),
    approved: integer("approved", { mode: "boolean" }).notNull(),
  },
  (t) => ({
    pk: primaryKey({ columns: [t.runId, t.nodeId, t.iteration] }),
  }),
);

Accessing previous iteration output with ctx.latest()

ctx.latest(table, nodeId) retrieves the most recent output for a task across all iterations.
ParameterTypeDescription
tableZodObject | Table | stringOutput target: schema from outputs, Drizzle table, or schema key (not SQLite table name).
nodeIdstringThe id prop of the target <Task>.
const { Workflow, smithers, outputs } = createSmithers({
  draft: z.object({ text: z.string(), score: z.number() }),
  review: z.object({ approved: z.boolean(), feedback: z.string() }),
});

export default smithers((ctx) => {
  const latestDraft = ctx.latest("draft", "write");   // string key, not table name
  const latestReview = ctx.latest("review", "review");

  return (
    <Workflow name="refine-loop">
      <Loop
        until={latestReview?.approved === true}
        maxIterations={5}
      >
        <Sequence>
          <Task id="write" output={outputs.draft} agent={writer}>
            {latestReview
              ? `Improve the draft. Feedback: ${latestReview.feedback}`
              : `Write a first draft about: ${ctx.input.topic}`}
          </Task>
          <Task id="review" output={outputs.review} agent={reviewer}>
            {`Review this draft (score: ${latestDraft?.score ?? "N/A"}):\n${latestDraft?.text ?? ""}`}
          </Task>
        </Sequence>
      </Loop>
    </Workflow>
  );
});
The re-render cycle drives iteration: after tasks complete, the tree re-renders with new outputs in context, until is re-evaluated, and the next iteration starts if not satisfied.
ctx.latest() returns the highest-iteration result. ctx.output() defaults to the current iteration, which may not have output yet at render time.

Accessing iteration count

<Loop
  until={ctx.iterationCount("review", "review") >= 2}
  maxIterations={5}
>
  <Task id="review" output={outputs.review} agent={reviewAgent}>
    {`This is iteration ${ctx.iteration}. Review the code.`}
  </Task>
</Loop>

Multiple loops

Use id to distinguish multiple loops in the same workflow:
<Workflow name="multi-loop">
  <Loop id="code-loop" until={codeApproved} maxIterations={3}>
    <Task id="write-code" output={outputs.writeCode} agent={codeAgent}>
      Write the implementation.
    </Task>
  </Loop>
  <Loop id="review-loop" until={reviewApproved} maxIterations={3}>
    <Task id="review-code" output={outputs.reviewCode} agent={reviewAgent}>
      Review the implementation.
    </Task>
  </Loop>
</Workflow>
When id is omitted, a stable id is generated from tree position.

onMaxReached behavior

ValueBehavior
"return-last"Keep final iteration output; workflow continues. Default.
"fail"Workflow fails with max-iteration error.
// Fail the workflow if we can't converge in 10 iterations
<Loop until={converged} maxIterations={10} onMaxReached="fail">
  <Task id="optimize" output={outputs.optimize} agent={optimizer}>
    Optimize the solution.
  </Task>
</Loop>

Full example

import { createSmithers } from "smithers-orchestrator";
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const { Workflow, Task, smithers, outputs } = createSmithers({
  review: z.object({
    approved: z.boolean(),
    feedback: z.string().nullable(),
  }),
});

const reviewAgent = new Agent({
  model: anthropic("claude-sonnet-4-20250514"),
  instructions: "You are a thorough code reviewer.",
});

export default smithers((ctx) => (
  <Workflow name="iterative-review">
    <Loop
      until={
        ctx.outputMaybe(outputs.review, { nodeId: "review" })?.approved === true
      }
      maxIterations={5}
      onMaxReached="return-last"
    >
      <Task id="review" output={outputs.review} agent={reviewAgent}>
        {`Review this code and either approve or provide feedback:\n\n${ctx.input.code}`}
      </Task>
    </Loop>
  </Workflow>
));

Rendering

<Loop> renders as a <smithers:ralph> host element (or null when skipped). The runtime manages iteration state and re-renders the tree each iteration.

Nested loops

Direct nesting — <Loop> as immediate child of <Loop> — throws at render time. Wrap the inner loop in <Sequence>:
<Workflow name="nested-loops">
  <Loop id="outer" until={outerDone} maxIterations={5}>
    <Sequence>
      <Loop id="inner" until={innerDone} maxIterations={3}>
        <Task id="innerTask" output="innerOutput" agent={agent}>
          Run the inner loop body.
        </Task>
      </Loop>
    </Sequence>
  </Loop>
</Workflow>

Restrictions

  • Direct nesting throws. Wrap the inner <Loop> in <Sequence>.
  • Duplicate ids throw. Two loops cannot share the same id.

Notes

  • until is evaluated at render time each frame. Typically references loop body output via ctx.outputMaybe().
  • Use ctx.outputMaybe() for until since output does not exist on the first render.
  • Custom Drizzle tables for tasks inside <Loop> must include iteration in the primary key. createSmithers(...) handles this automatically.
  • The iteration counter resets to 0 at the start of each workflow run.