Skip to main content

Ralph Loop

Ralph is the looping primitive in Smithers. It re-executes its children until a condition is met or a maximum iteration count is reached. This example demonstrates a write-then-review cycle: one agent writes code, another reviews it, and the loop continues until the reviewer approves.

Workflow Definition

// ralph-loop.tsx
import { createSmithers, Task, Sequence, Ralph } from "smithers-orchestrator";
import { ToolLoopAgent as Agent } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";

const { Workflow, smithers } = createSmithers({
  code: z.object({
    source: z.string(),
    language: z.string(),
  }),
  review: z.object({
    approved: z.boolean(),
    feedback: z.string(),
  }),
  finalOutput: z.object({
    source: z.string(),
    iterations: z.number(),
  }),
});

const coder = new Agent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions:
    "You are an expert programmer. Write or revise code based on the given requirements and feedback.",
});

const reviewer = new Agent({
  model: anthropic("claude-sonnet-4-5-20250929"),
  instructions:
    "You are a strict code reviewer. Evaluate the code for correctness, style, and edge cases. Set approved to true only if the code is production-ready.",
});

export default smithers((ctx) => {
  const latestReview = ctx.outputMaybe("review", { nodeId: "review" });
  const latestCode = ctx.outputMaybe("code", { nodeId: "write" });

  return (
    <Workflow name="ralph-loop">
      <Sequence>
        <Ralph
          id="revision-loop"
          until={latestReview?.approved === true}
          maxIterations={5}
          onMaxReached="return-last"
        >
          <Sequence>
            <Task id="write" output="code" agent={coder}>
              Write a TypeScript function that debounces an input function.
              {latestReview
                ? ` Revise based on this feedback: ${latestReview.feedback}`
                : ""}
            </Task>

            <Task id="review" output="review" agent={reviewer}>
              Review this code for correctness and edge cases:
              {"\n\n```" + (latestCode?.language ?? "ts") + "\n"}
              {latestCode?.source ?? "// no code yet"}
              {"\n```"}
            </Task>
          </Sequence>
        </Ralph>

        {/* After the loop ends, emit the final result */}
        <Task id="final" output="finalOutput">
          {{
            source: latestCode?.source ?? "",
            iterations: ctx.iterationCount("code", "write"),
          }}
        </Task>
      </Sequence>
    </Workflow>
  );
});

Running the Workflow

smithers run ralph-loop.tsx --input '{}'
Example output showing two iterations before approval:
[ralph-loop] Starting run ghi789
[revision-loop] Iteration 1
  [write] Done -> { source: "function debounce(fn, ms) { ... }", language: "ts" }
  [review] Done -> { approved: false, feedback: "Missing generic types; no cancel method." }
[revision-loop] Iteration 2
  [write] Done -> { source: "function debounce<T>(fn: T, ms: number) { ... cancel() ... }", language: "ts" }
  [review] Done -> { approved: true, feedback: "Looks good. Generics and cancel are correct." }
[final] Done -> { source: "function debounce<T>(...) { ... }", iterations: 2 }
[ralph-loop] Completed

How Ralph Works

PropDescription
idUnique identifier for the loop node.
untilA boolean expression. When true, the loop stops.
maxIterationsSafety cap on iterations (default: 5).
onMaxReached"fail" throws an error; "return-last" exits gracefully with the last output.
  • Each iteration re-renders the children with updated context. The ctx.outputMaybe calls always return the latest value from the most recent iteration.
  • Use ctx.iterationCount(table, nodeId) to find out how many times a particular task has executed.
  • All intermediate outputs are persisted, so the loop is resumable. If the workflow crashes mid-iteration, it restarts from the last incomplete task within the current iteration.

Understanding ctx.outputMaybe() and ctx.latest() parameter types

In the example above, ctx.outputMaybe("review", { nodeId: "review" }) takes a schema key as the first argument — that is, the key from the object you passed to createSmithers(). This is "review" because the schema was defined as:
const { Workflow, smithers } = createSmithers({
  code: z.object({ ... }),     // schema key: "code"
  review: z.object({ ... }),   // schema key: "review"
  finalOutput: z.object({ ... }), // schema key: "finalOutput"
});
The schema key is not the SQLite table name. Smithers converts camelCase keys to snake_case table names internally (e.g. "finalOutput" becomes "final_output" in SQLite). Always use the original camelCase key when calling context methods. Similarly, ctx.latest("code", "write") uses:
  • "code" — the schema key, matching the key in createSmithers()
  • "write" — the id prop of the <Task id="write" ...> element
You can also use ctx.latest() instead of ctx.outputMaybe() inside Ralph loops. The difference is that ctx.latest() always returns the highest-iteration row, while ctx.outputMaybe() defaults to matching the current iteration. Inside a Ralph loop, ctx.latest() is often more convenient because it finds the most recent output without you needing to specify an iteration number.

The re-render cycle

Ralph drives iteration through tree re-rendering:
  1. The workflow builder function (ctx) => (...) is called on every render frame.
  2. On the first render, ctx.outputMaybe("review", { nodeId: "review" }) returns undefined. The write task has no feedback to work from, so it produces an initial draft.
  3. After both tasks in the sequence complete, the renderer persists their outputs and re-renders the tree.
  4. On the second render, latestReview is now populated with the iteration-0 review. Ralph evaluates until={latestReview?.approved === true}.
  5. If not approved, the loop body executes again. The write task now sees the review feedback and revises its code.
  6. This cycle repeats until approved or maxIterations is reached.