Skip to content

Review loop ​

The problem: an agent writes code, and you want a second agent to check it against rules the first wasn't given, and have it fixed until it passes, without a person relaying feedback.

The workflow:examples/messaging. An implementer writes slugify() in a fresh folder. A reviewer that knows a house style for slugs runs the code on three titles, messages the implementer the fixes it needs, waits for each, and runs the code again. The workflow then checks the reviewer's slugs against the known answers.

From a clone of the repository, after bun install:

sh
bun awf run examples/messaging/workflow.ts            # both agents on codex
bun awf run examples/messaging/workflow.ts -- claude-tui

Cost and time: not recorded yet. They come from a recorded run of the example, priced at list prices; Cost has where an estimate comes from.

The workflow ​

ts
import {
  defineWorkflow,
  type ExecutionConfig,
  type OutputSchema,
  type WorkflowInvocation,
} from "agentswf/workflow";

// An implementer writes slugify() in a fresh folder; a reviewer who knows the house style it was
// not told runs the code, asks for the fix by message, and checks again. Nothing to set up:
//
//   awf run examples/messaging/workflow.ts [-- codex | claude | pi | claude-tui]
//
// Both agents run the runtime named, codex when none is; each is one that can wait for a reply.

export const RUNTIMES = {
  codex: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
  pi: {
    harness: "pi",
    model: "openrouter/deepseek/deepseek-v4.1-flash-1017",
    placement: "headless",
  },
  claude: { harness: "claude", model: "claude-sonnet-5-5", placement: "headless", metered: true },
  "claude-tui": { harness: "claude", model: "claude-sonnet-5-5" },
} as const satisfies Record<string, ExecutionConfig>;
export type RuntimeName = keyof typeof RUNTIMES;

/** What the house style makes of each title; the implementer is told none of it. */
export const CASES = [
  { title: "Hello, World!", slug: "hello_world" },
  { title: "  Crème Brûlée Recipes ", slug: "creme_brulee_recipes" },
  { title: "awf 2.0: Agents Talk", slug: "awf_2_0_agents_talk" },
] as const;

export const BUILT: OutputSchema<{ file: string }> = {
  type: "object",
  properties: { file: { type: "string" } },
  required: ["file"],
  additionalProperties: false,
};

export const REVIEW: OutputSchema<{
  approved: boolean;
  outputs: { title: string; slug: string }[];
}> = {
  type: "object",
  properties: {
    approved: { type: "boolean" },
    outputs: {
      type: "array",
      items: {
        type: "object",
        properties: { title: { type: "string" }, slug: { type: "string" } },
        required: ["title", "slug"],
        additionalProperties: false,
      },
    },
  },
  required: ["approved", "outputs"],
  additionalProperties: false,
};

export type MessagingResult = {
  folder: string;
  approved: boolean;
  /** Each slug the reviewer got by running the code, beside the house style's. */
  checks: { title: string; expected: string; got?: string }[];
  /** The implementer's turns that a message started. */
  revisions: number;
};

export const messaging = defineWorkflow<{ runtime: RuntimeName }, MessagingResult>({
  meta: {
    name: "messaging",
    description: "A reviewer asks an implementer for a fix by message and waits for it.",
  },
  async run(wf, { runtime }) {
    const execution = RUNTIMES[runtime];
    const folder = `/tmp/awf-messaging/${wf.runId}`;
    const implementer = await wf.agents.open({ key: "implementer", runtime: execution });
    const reviewer = await wf.agents.open({
      key: "reviewer",
      runtime: execution,
      instructions: [
        "You review code against the house style for slugs: lowercase ASCII, accents dropped,",
        "every run of other characters one underscore, none at either end.",
        ...CASES.map(({ title, slug }) => `${JSON.stringify(title)} -> ${slug}`),
      ].join("\n"),
    });
    await wf.messages.allow({
      from: reviewer,
      to: implementer,
      purpose: "Ask the implementer to fix slugify() until it meets the house style.",
      maxMessages: 4,
    });

    const built = await implementer.run({
      label: "Implement",
      prompt: [
        `Create the folder ${folder} and write ${folder}/slug.mjs in it, exporting`,
        "slugify(title), which turns a title into a URL slug. Plain JavaScript, no dependencies.",
        "Work only in that folder.",
      ].join(" "),
      schema: BUILT,
    });
    if (!built.outcome.answered) return wf.stop(`implementer: ${built.outcome.reason}`);

    const review = await reviewer.run({
      label: "Review",
      prompt: [
        `Review ${built.outcome.value.file}. Run slugify() on each title in your instructions`,
        "with node. If any slug is wrong, message the implementer the house style's rule and, for",
        "each wrong slug, its title, the slug it got and the slug expected; wait for its reply,",
        "then run them all again. Return the slugs you got on your last run.",
      ].join(" "),
      schema: REVIEW,
    });
    if (!review.outcome.answered) return wf.stop(`reviewer: ${review.outcome.reason}`);

    const { approved, outputs } = review.outcome.value;
    return {
      folder,
      approved,
      checks: CASES.map(({ title, slug }) => {
        const got = outputs.find((output) => output.title === title)?.slug;
        return got === undefined ? { title, expected: slug } : { title, expected: slug, got };
      }),
      revisions: wf
        .usage()
        .filter((operation) => operation.agent === "implementer" && operation.cause).length,
    };
  },
  prepare: parseRuntime,
  present: ({ folder, approved, checks, revisions }) =>
    [
      `${approved ? "approved" : "not approved"} after ${revisions} revision${revisions === 1 ? "" : "s"} by message; code in ${folder}`,
      ...checks.map(
        ({ title, expected, got }) =>
          `${got === expected ? "right" : "wrong"}  ${JSON.stringify(title)} -> ${got ?? "(not run)"}`,
      ),
    ].join("\n"),
});

function parseRuntime({ argv }: WorkflowInvocation): { runtime: RuntimeName } {
  const [runtime = "codex", ...rest] = argv;
  if (!Object.hasOwn(RUNTIMES, runtime) || rest.length > 0) {
    throw new Error(`messaging: -- [${Object.keys(RUNTIMES).join(" | ")}]`);
  }
  return { runtime: runtime as RuntimeName };
}

export default messaging;

Its test is workflow.test.ts.

Why it's shaped this way ​

  • The reviewer owns the loop. The code makes two calls, implementer.run and reviewer.run, and every round of review and fix happens before reviewer.run resolves. The reviewer sends the fixes with wfa send --await-reply implementer and ends its turn; the message starts an implementer turn, and the implementer's reply starts the reviewer's next turn, in the same session. reviewer.run resolves when the reviewer hands back its answer. Your code doesn't count rounds or carry findings between prompts. Messaging has how a route works.
  • The route is the bound. wf.messages.allow lets only the reviewer start a message, and maxMessages: 4 caps the conversation, replies included: two rounds of fix and answer. Past that the reviewer can't ask again and has to return what it has.
  • The reviewer knows what the implementer doesn't. The house style is in the reviewer's instructions only. A reviewer that holds the acceptance criteria, ideally on another model, doesn't share the implementer's blind spots.
  • The message carries the evidence. The prompt has the reviewer send the rule and, for each wrong slug, the title, the slug it got and the one expected. The implementer was never told the house style, so a message that only says "fix it" leaves it guessing.
  • The reviewer checks evidence, not prose. It runs the code and reports the slugs it got. The workflow then compares them with CASES itself, so approved is the reviewer's opinion and checks is the fact, and a wrong slug the reviewer let through still shows wrong.
  • Every turn's outcome is checked. A turn that didn't answer stops the run with its reason rather than reading a value that isn't there.
  • The record says what happened. revisions counts the implementer's turns that a message started, read from wf.usage(), so the result shows how many rounds it took.

When to write the loop in code instead ​

The loop can live in your workflow instead: a for that runs the implementer, asks the reviewer for a typed verdict, and sends the findings back, up to a number of rounds. Loops builds that one. Choose it when:

  • the reviewer's harness or placement can't wait for a reply (a reviewer here must be one that can, which is why the example runs both agents headless or claude in its TUI);
  • your code needs each round's findings, to log, gate or show them;
  • each round should be a fresh reviewer, or several reviewers in parallel. minimum-review is that review step on its own: a correctness and a maintainability lens at once, claude and codex, each answering with typed findings.

Catches ​

  • An approval is a claim. Check what you can in code, as checks does here. Where you can't, have the reviewer report evidence (commands run, their output), not a verdict alone.
  • A review step in a larger run wants its route granted inside a stage, so the conversation ends with the stage. Plan, implement, review does that twice.
  • A loop can end unapproved. With a bound, it has to. Give the result a field that says so, and what is still open, for a person or the next workflow.
  • Messages cost turns. Each message the reviewer sends starts an implementer turn, so maxMessages bounds the cost as well as the rounds.

See also ​