Multi-Agent Systems
Split a large task across an orchestrator agent and specialist agents that work in parallel.
An orchestrator agent splits a job into subtasks, hands each one to its own specialist agent, and combines what comes back. Here a research job fans out to researchers that work in parallel, and a reviewer checks the finished report.
What we’ll build:
- An
orchestratoragent per job that breaks the job into subtasks and keeps the plan and the collected answers in its conversation. - An
ask_specialisttool that starts oneresearcheragent per subtask, runs them in parallel, and waits for every answer. - A
revieweragent per job that gets the finished report as an agent-to-agent message, without the orchestrator waiting for the review. - A client that starts each job by calling its orchestrator directly and reads the review from its reviewer.
- Recovery after a crash, so a finished subtask is not asked again and an unfinished one carries on where it stopped.
Install
npm add @rivet-dev/pi @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord rivetkit
Create the researcher
Each subtask gets its own researcher, with its own tools and a fresh conversation:
import { Type } from "@earendil-works/pi-ai";
import { createRegistry, defineExtension, defineTool, section } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
const fetchPage = defineTool({
name: "fetch_page",
description: "Fetch a web page and return the start of its text.",
parameters: Type.Object({ url: Type.String() }),
// A fetch changes nothing, so running it again after a crash is harmless.
replay: "safe",
execute: async ({ url }, _api, context) => {
const response = await fetch(url, { signal: context.abortSignal });
if (!response.ok) throw new Error(`${url} returned ${response.status}.`);
const text = (await response.text()).slice(0, 20_000);
return { content: [{ type: "text", text }] };
},
});
const extensions = createRegistry();
extensions.install(
defineExtension({
name: "research",
tools: [fetchPage],
sections: [
section(
"role",
() => "You research one question. Answer it in a few short paragraphs and list the URLs you used.",
),
],
}),
);
// One researcher per subtask, with a fresh conversation.
export const researcher = pi({ model: "openai/gpt-5.4-mini", registry: extensions });
- The researcher only sees the question it’s given, not the orchestrator’s conversation, so its context stays small and on topic.
fetch_pagereads a web page. Give a specialist only the tools its job needs. See Custom Tools.- It runs on a smaller, cheaper model than the orchestrator, because each subtask is narrow.
Fan out to researchers
The orchestrator’s ask_specialist tool takes every subtask at once and starts one researcher for each:
import { Type } from "@earendil-works/pi-ai";
import {
createRegistry,
defineExtension,
defineTool,
ROOT_CONVERSATION_ID,
section,
} from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const askSpecialist = defineTool({
name: "ask_specialist",
description: "Give each subtask to its own researcher. They work in parallel, and you get every answer back.",
parameters: Type.Object({
subtasks: Type.Array(
Type.String({ description: "One self-contained question. The researcher can't see this conversation." }),
{ minItems: 1, maxItems: 8 },
),
}),
// A rerun after a crash reaches the same researchers, through the memo below.
replay: "safe",
execute: async ({ subtasks }, api, context) => {
// One id per subtask, saved with this tool call, so a rerun gets the same ids.
const ids = await api.memo("researcher-ids", subtasks.map(() => crypto.randomUUID()), context);
const answers = await Promise.all(
ids.map(async (id, i) => {
const researcher = client.researcher.getOrCreate([id]);
context.abortSignal?.addEventListener("abort", () => void researcher.conversation.abort(ROOT_CONVERSATION_ID));
// A researcher that already answered returns its saved answer for the same requestId.
const result = await researcher.prompt(subtasks[i], { requestId: id });
if (result.status === "unanswered") return `No answer: ${result.reason}`;
return result.text || "The researcher finished without an answer.";
}),
);
const text = subtasks.map((subtask, i) => `## ${subtask}\n\n${answers[i]}`).join("\n\n");
return { content: [{ type: "text", text }] };
},
});
const extensions = createRegistry();
extensions.install(
defineExtension({
name: "orchestrator",
tools: [askSpecialist],
sections: [
section(
"role",
() =>
"You run research jobs. Split the job into independent subtasks, send them all to ask_specialist in one call, and combine the answers into one report.",
),
],
}),
);
// One orchestrator per job, keyed by the job id.
export const orchestrator = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
// Hands every finished report to the job's reviewer without waiting for the review.
onAnswer: async (c, answer) => {
const [jobId] = c.key;
if (!answer.text) return;
// The answer's timestamp is saved with it, so a repeated call sends the same requestId.
await client.reviewer.getOrCreate([jobId]).requestReview(answer.text, `${jobId}:${answer.message.timestamp}`);
},
});
const client = createClient<typeof registry>();
- The model writes the plan as the list of
subtasks. Each researcher is keyed by its own id, so every subtask gets a new agent. Promise.allprompts every researcher at once and waits for all of them. The combined answers go back to the model as one tool result, which stays in the orchestrator’s conversation.- If the orchestrator’s run is cancelled, the tool cancels every researcher’s run too.
maxItemscaps how many researchers one call can start.
An action waits up to ten minutes. A researcher that takes longer keeps working, but its prompt rejects and the tool call fails. Keep subtasks small enough to finish in that time.
Create the orchestrator
The orchestrator has ask_specialist and a prompt that tells it how to split a job:
import { Type } from "@earendil-works/pi-ai";
import {
createRegistry,
defineExtension,
defineTool,
ROOT_CONVERSATION_ID,
section,
} from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const askSpecialist = defineTool({
name: "ask_specialist",
description: "Give each subtask to its own researcher. They work in parallel, and you get every answer back.",
parameters: Type.Object({
subtasks: Type.Array(
Type.String({ description: "One self-contained question. The researcher can't see this conversation." }),
{ minItems: 1, maxItems: 8 },
),
}),
// A rerun after a crash reaches the same researchers, through the memo below.
replay: "safe",
execute: async ({ subtasks }, api, context) => {
// One id per subtask, saved with this tool call, so a rerun gets the same ids.
const ids = await api.memo("researcher-ids", subtasks.map(() => crypto.randomUUID()), context);
const answers = await Promise.all(
ids.map(async (id, i) => {
const researcher = client.researcher.getOrCreate([id]);
context.abortSignal?.addEventListener("abort", () => void researcher.conversation.abort(ROOT_CONVERSATION_ID));
// A researcher that already answered returns its saved answer for the same requestId.
const result = await researcher.prompt(subtasks[i], { requestId: id });
if (result.status === "unanswered") return `No answer: ${result.reason}`;
return result.text || "The researcher finished without an answer.";
}),
);
const text = subtasks.map((subtask, i) => `## ${subtask}\n\n${answers[i]}`).join("\n\n");
return { content: [{ type: "text", text }] };
},
});
const extensions = createRegistry();
extensions.install(
defineExtension({
name: "orchestrator",
tools: [askSpecialist],
sections: [
section(
"role",
() =>
"You run research jobs. Split the job into independent subtasks, send them all to ask_specialist in one call, and combine the answers into one report.",
),
],
}),
);
// One orchestrator per job, keyed by the job id.
export const orchestrator = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
// Hands every finished report to the job's reviewer without waiting for the review.
onAnswer: async (c, answer) => {
const [jobId] = c.key;
if (!answer.text) return;
// The answer's timestamp is saved with it, so a repeated call sends the same requestId.
await client.reviewer.getOrCreate([jobId]).requestReview(answer.text, `${jobId}:${answer.message.timestamp}`);
},
});
const client = createClient<typeof registry>();
- Each job gets its own
orchestrator, keyed by the job id, so jobs never share a conversation. onAnswerruns for every final answer, with the orchestrator’s own context, soc.keyis the job id. It sends the report to that job’s reviewer.- It runs inside the orchestrator’s run, so a report that finishes just before a crash still reaches the reviewer. If it runs twice for the same answer, the reviewer gets the same
requestIdand reviews it once.
Create the reviewer
The reviewer takes the report as a message and returns before reviewing it:
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { createRegistry, defineExtension, section } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
const extensions = createRegistry();
extensions.install(
defineExtension({
name: "review",
sections: [
section(
"role",
() =>
"You review research reports. List claims without a source, gaps, and contradictions, then end with APPROVE or REVISE.",
),
],
}),
);
// One reviewer per job, keyed by the job id.
export const reviewer = pi({
model: "openai/gpt-5.5",
registry: extensions,
state: { review: null as string | null },
// Saves each review so your app can read it.
onAnswer: (c, answer) => {
c.state.review = answer.text ?? null;
},
actions: {
// Saves the request and returns without waiting for the review.
requestReview: async (c, report: string, requestId: string) => {
const conversation = await c.pi.root(BACKGROUND_CONTEXT);
await conversation.submit(
{ type: "input", content: `Review this report:\n\n${report}`, requestId },
BACKGROUND_CONTEXT,
);
},
getReview: (c) => c.state.review,
},
});
requestReviewsubmits the report to the reviewer’s conversation and returns, so the orchestrator never waits for the review. The request is saved before the action returns. See Agent-to-Agent Messages.- The reviewer runs on a different model family than the orchestrator, so it doesn’t share the same blind spots.
- Its
onAnswersaves the review in the reviewer’s state, andgetReviewreturns it to your app.
Register the agents
All three agents go in one registry:
import { setup } from "rivetkit";
import { orchestrator } from "./orchestrator";
import { researcher } from "./researcher";
import { reviewer } from "./reviewer";
export const registry = setup({ use: { orchestrator, researcher, reviewer } });
registry.start();
There are no routes to write. Clients call each agent’s actions by key through the RivetKit client.
Start a job
Your app calls submit on the orchestrator for the job:
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
// Each job gets its own orchestrator, keyed by the job id.
const jobId = crypto.randomUUID();
const orchestrator = client.orchestrator.getOrCreate([jobId]);
// Starts the job and returns without waiting for it.
// The job id is also the requestId, so a retried call doesn't start the job twice.
await orchestrator.submit("Compare SQLite, Postgres, and DuckDB for an analytics dashboard with 50 million rows.", {
requestId: jobId,
});
console.log(`Started job ${jobId}`);
// Check the job's reviewer every 30 seconds until the review is in.
const reviewer = client.reviewer.getOrCreate([jobId]);
let review = await reviewer.getReview();
while (review === null) {
await new Promise((resolve) => setTimeout(resolve, 30_000));
review = await reviewer.getReview();
}
console.log(review);
- The app picks the job id and keys the orchestrator by it. The first call creates that job’s orchestrator.
submitstarts the job and returns without waiting, so a job that takes many minutes never holds a call open.- The job id is also the
requestId, so a retried call runs the job once.
Any client can start a job or read a review. In production, protect the Actors with authentication.
Recover from a crash
Nothing extra is needed. Each agent is its own Rivet Actor that saves every step to its own SQLite database, and the fan-out tool is written so a rerun is safe:
ask_specialistsetsreplay: "safe", so if the orchestrator crashes while researchers are working, the tool runs again when the orchestrator restarts.api.memosaves the researcher ids with the tool call, so the rerun reaches the same researchers instead of starting new ones.- A researcher that already answered returns its saved answer for the same
requestIdwithout running again. - A researcher that was still working kept going in its own Actor, or resumed from its last saved step if it crashed too. The rerun waits for that run instead of asking again.
See Resuming a run for what happens to each kind of step.
Try it
Set the keys for both model providers and start the server:
export ANTHROPIC_API_KEY=sk-ant-...
export OPENAI_API_KEY=sk-...
npx tsx server.ts
In another terminal, start a job and wait for its review:
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
// Each job gets its own orchestrator, keyed by the job id.
const jobId = crypto.randomUUID();
const orchestrator = client.orchestrator.getOrCreate([jobId]);
// Starts the job and returns without waiting for it.
// The job id is also the requestId, so a retried call doesn't start the job twice.
await orchestrator.submit("Compare SQLite, Postgres, and DuckDB for an analytics dashboard with 50 million rows.", {
requestId: jobId,
});
console.log(`Started job ${jobId}`);
// Check the job's reviewer every 30 seconds until the review is in.
const reviewer = client.reviewer.getOrCreate([jobId]);
let review = await reviewer.getReview();
while (review === null) {
await new Promise((resolve) => setTimeout(resolve, 30_000));
review = await reviewer.getReview();
}
console.log(review);
npx tsx client.ts
To test recovery, kill the server with kill -9 while the researchers are working, then start it again. The job finishes, and the client prints the review.
Next steps
- Durable AI Agents: stream each agent’s work to a client as it happens.
- Sandboxes: give a specialist a computer to run code and browse.
- Workflows: run agents as fixed steps with retries and waits.
- Human in the Loop: ask a person before a report goes out.
- Observability: trace each agent’s runs.
Related docs
- Subagents: one lead agent that waits for one specialist.
- Agent-to-Agent Messages: hand work to another agent without waiting.
- Design Patterns: how to key agents per job, user, or task.
- Pi:
onAnswer,c.pi, and every action an agent has. - Architecture: what survives sleep, crashes, and upgrades.