Design Patterns
Patterns for agent keys, subagents, agent-to-agent messages, workflows, schedules, and shared credentials.
Each agent is an Actor, so agent apps use the same building blocks as the rest of Rivet: keys pick the agent, and agents call other Actors.
One Agent per Key
The key decides which conversation a prompt continues. Pick it from what the agent should remember:
- Per user: a personal assistant remembers everything a user has asked, across every chat.
- Per thread: each Slack thread, support ticket, or chat tab is its own conversation.
- Per task: each job, such as fixing one GitHub issue, starts fresh in its own sandbox. When it’s done, an action that calls
c.destroy()deletes the agent and its sandbox, likefinishbelow.
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { setup } from "rivetkit";
const assistant = pi({ model: "anthropic/claude-opus-5-5" });
const slackThread = pi({ model: "anthropic/claude-haiku-4-5" });
const issueFixer = pi({
model: "anthropic/claude-opus-5-5",
sandbox: e2bProvider(),
actions: {
finish: (c) => c.destroy(),
},
});
export const registry = setup({ use: { assistant, slackThread, issueFixer } });
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
export async function chat(userId: string, message: string) {
const assistant = client.assistant.getOrCreate([userId]);
await assistant.prompt(message);
return assistant.getLastAssistantText();
}
export async function onSlackMessage(channel: string, threadTs: string, text: string) {
const thread = client.slackThread.getOrCreate([channel, threadTs]);
await thread.prompt(text);
return thread.getLastAssistantText();
}
export async function onIssueLabeled(repo: string, issue: { number: number; title: string; body: string }) {
const fixer = client.issueFixer.getOrCreate([repo, String(issue.number)]);
try {
await fixer.prompt(
`Clone https://github.com/${repo}, fix issue #${issue.number}, and open a pull request.\n\n# ${issue.title}\n\n${issue.body}`,
);
return await fixer.getLastAssistantText();
} finally {
await fixer.finish();
}
}
Keys are arrays, so a channel id or repository name from a webhook can’t break the key’s structure. See Actor Keys.
Lead Agent with Subagents
A lead agent hands a focused task to a specialist with its own tools and waits for its answer. The specialist starts with a fresh context, so only the answer comes back.
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
import { askSpecialist, engineering, orders } from "./specialists";
const support = pi({ model: "anthropic/claude-opus-5-5", customTools: [askSpecialist] });
export const registry = setup({ use: { support, orders, engineering } });
registry.start();
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import type { Registry } from "rivetkit";
import { createClient } from "rivetkit/client";
import { getOrder } from "../custom-tools/get-order";
export const orders = pi({ model: "openai/gpt-5.4-mini", customTools: [getOrder] });
export const engineering = pi({ model: "anthropic/claude-opus-5-5", sandbox: e2bProvider() });
export const askSpecialist = defineTool({
name: "ask_specialist",
label: "Ask a specialist",
description:
"Hand one focused question to a specialist and get its answer. Use orders for order status, refunds, and shipping. Use engineering for bugs that need someone to read or run the code.",
parameters: Type.Object({
specialist: Type.Union([Type.Literal("orders"), Type.Literal("engineering")]),
question: Type.String({ description: "Everything the specialist needs. It can't see this conversation." }),
}),
async execute(_toolCallId, { specialist, question }, _signal, _onUpdate, ctx) {
const agent = client[specialist].getOrCreate([ctx.sessionManager.getSessionId()]);
await agent.prompt(question);
const answer = (await agent.getLastAssistantText()) ?? "The specialist finished without an answer.";
return { content: [{ type: "text", text: answer }], details: { specialist } };
},
});
const client = createClient<Registry<{ orders: typeof orders; engineering: typeof engineering }>>();
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const support = client.support.getOrCreate(["ticket-4821"]);
await support.prompt(
"Order 1042 shows as delivered but never arrived, and the tracking page throws a 500 error. Repo: https://github.com/acme/storefront",
);
console.log(await support.getLastAssistantText());
See Subagents.
Agents Messaging Each Other
When an agent hands work off and doesn’t need the answer, it sends a message instead of waiting. The receiving agent schedules its own prompt, so the message survives the sender going away.
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-coding-agent";
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const requestReview = defineTool({
name: "request_review",
label: "Request review",
description: "Send a finished branch to the repository's reviewer. Don't wait for the review.",
parameters: Type.Object({ repo: Type.String(), branch: Type.String(), summary: Type.String() }),
async execute(_toolCallId, { repo, branch, summary }) {
await client.reviewer.getOrCreate([repo]).requestReview(`Review the ${branch} branch of https://github.com/${repo}: ${summary}`);
return { content: [{ type: "text", text: `Sent ${branch} for review.` }], details: undefined };
},
});
export const coder = pi({
model: "anthropic/claude-opus-5-5",
sandbox: e2bProvider(),
customTools: [requestReview],
});
const client = createClient<typeof registry>();
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { setup } from "rivetkit";
import { coder } from "./coder";
const reviewer = pi({
model: "openai/gpt-5.5",
sandbox: e2bProvider(),
actions: {
requestReview: async (c, request: string) => {
await c.schedule.after(0, "prompt", request, { streamingBehavior: "followUp" });
},
},
});
export const registry = setup({ use: { coder, reviewer } });
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const coder = client.coder.getOrCreate(["acme/app", "fix-flaky-checkout-test"]);
await coder.prompt(
"Clone https://github.com/acme/app, fix the flaky checkout test on a new fix-flaky-checkout-test branch, push it, and request a review.",
);
console.log(await coder.getLastAssistantText());
Agent as a Workflow Step
When the work has fixed steps, waits, or retries, let a workflow drive the agent. Each step calls abort first, so a retried step doesn’t collide with a run the previous attempt left going.
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { type Registry, setup, workflow } from "@rivet-dev/workflows";
const agent = pi({
model: "anthropic/claude-opus-5-5",
sandbox: e2bProvider(),
});
type Agents = Registry<{ agent: typeof agent }>;
const flakyTest = workflow({
state: { report: null as string | null },
run: async (ctx) => {
await ctx.step({
name: "first-run",
timeout: 10 * 60_000,
run: async (step) => {
const fixer = step.client<Agents>().agent.getOrCreate([step.actorId]);
await fixer.abort();
await fixer.prompt("Clone https://github.com/acme/app, run its test suite, and note any failing tests.");
},
});
await ctx.sleep("wait-before-rerun", 10 * 60_000);
await ctx.step({
name: "second-run",
timeout: 10 * 60_000,
run: async (step) => {
const fixer = step.client<Agents>().agent.getOrCreate([step.actorId]);
await fixer.abort();
await fixer.prompt("Run the suite again. Which failures happened both times?");
step.state.report = (await fixer.getLastAssistantText()) ?? null;
},
});
},
actions: {
getReport: (c) => c.state.report,
},
});
export const registry = setup({ use: { agent, flakyTest } });
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
// Creating the workflow starts it. The key names this run.
const run = client.flakyTest.getOrCreate(["acme/app", "2026-10-01"]);
let report = await run.getReport();
while (report === null) {
await new Promise((resolve) => setTimeout(resolve, 60_000));
report = await run.getReport();
}
console.log(report);
See Workflows.
Scheduled Agents
An agent can wake itself on a schedule and sleep between runs.
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { setup } from "rivetkit";
const agent = pi({
model: "anthropic/claude-sonnet-5-5",
sandbox: e2bProvider(),
onCreate: async (c) => {
const [repo] = c.key;
await c.cron.set({
name: "morning-triage",
expression: "0 9 * * *",
action: "prompt",
args: [`Clone https://github.com/${repo}, run its test suite, and summarize any failures.`],
});
},
});
export const registry = setup({ use: { agent } });
registry.start();
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const agent = client.agent.getOrCreate(["acme/app", "morning-triage"]);
// The first call creates the agent, and onCreate sets its cron.
await agent.resolve();
// Any time after a run, read the latest summary.
const summary = await agent.getLastAssistantText();
console.log(summary ?? "No run yet. The first one starts at 9:00 UTC.");
See Schedules.
Credentials Shared per Tenant
Start agent keys with the tenant id, and pick the credentials Actor from it. Every agent in a tenant shares one set of model logins, and never sees another tenant’s.
import { pi } from "@rivet-dev/pi";
import { type Registry, setup } from "rivetkit";
import { credentials } from "../../user-subscriptions/credentials";
const agent = pi({
model: "anthropic/claude-opus-5-5",
// Agent keys start with the tenant id, so every agent in a tenant reads the same credentials Actor.
credentials: (c) => {
const [tenantId] = c.key;
const client = c.client<Registry<{ credentials: typeof credentials }>>();
return client.credentials.getOrCreate([tenantId]);
},
});
export const registry = setup({ use: { credentials, agent } });
registry.start();
import { type Credential, InMemoryCredentialStore } from "@earendil-works/pi-ai";
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { PiProviderCredential } from "@rivet-dev/pi";
import { actor } from "rivetkit";
export const credentials = actor({
state: { saved: {} as Record<string, Credential> },
actions: {
save: (c, provider: string, credential: Credential) => {
c.state.saved[provider] = credential;
},
list: (c) => Object.entries(c.state.saved).map(([providerId, { type }]) => ({ providerId, type })),
read: (c, provider: string) => withoutRefreshToken(c.state.saved[provider]),
refresh: async (c, provider: string) => {
const store = new InMemoryCredentialStore();
await store.modify(provider, async () => c.state.saved[provider]);
const runtime = await ModelRuntime.create({ credentials: store, modelsPath: null });
await runtime.getAuth(provider, { minOAuthValidityMs: 10 * 60_000 });
const refreshed = await store.read(provider);
if (refreshed) c.state.saved[provider] = refreshed;
return withoutRefreshToken(refreshed);
},
},
});
function withoutRefreshToken(credential: Credential | undefined): PiProviderCredential | undefined {
if (credential?.type !== "oauth") return credential;
const { refresh: _refresh, ...rest } = credential;
return rest;
}
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
// Called from your settings page when a tenant admin saves the team's Anthropic key.
export async function saveTeamKey(tenantId: string, anthropicKey: string) {
await client.credentials.getOrCreate([tenantId]).save("anthropic", { type: "api_key", key: anthropicKey });
}
// Every agent keyed under the tenant uses that key, and never sees another tenant's.
export async function ask(tenantId: string, userId: string, text: string) {
const agent = client.agent.getOrCreate([tenantId, userId]);
await agent.prompt(text);
return agent.getLastAssistantText();
}
See User Subscriptions.
Anti-Patterns
One Agent for Every User
An agent runs one prompt at a time. With a single key for everyone, a prompt from one user throws while another user’s prompt is running, and every user shares one session.
import { createClient } from "rivetkit/client";
import type { registry } from "./agent-per-key/server";
const client = createClient<typeof registry>();
export async function answer(userId: string, message: string) {
const assistant = client.assistant.getOrCreate(["support"]);
await assistant.prompt(`${userId}: ${message}`);
return assistant.getLastAssistantText();
}
Solution: Key the agent by user, thread, or task.
A New Key per Request
A new key creates a new agent with an empty session, so the agent forgets the conversation after every message and leaves an Actor behind.
import { createClient } from "rivetkit/client";
import type { registry } from "./agent-per-key/server";
const client = createClient<typeof registry>();
export async function answer(message: string) {
const assistant = client.assistant.getOrCreate([crypto.randomUUID()]);
await assistant.prompt(message);
return assistant.getLastAssistantText();
}
Solution: Reuse the key of the conversation the message belongs to.
Where to Go Next
| Goal | Read |
|---|---|
| Give agents model access | LLM API Keys |
| Give the agent your own APIs | Custom Tools |
| Let the agent run commands and edit files | Sandboxes, then Built-in Tools |
| Run agents on your users’ own subscriptions | User Subscriptions |
| Wait for a person before a risky action | Human in the Loop |
| Share a result through a link | Scoped Access with JWTs |
| Talk to users in Slack, Linear, GitHub, or Discord | Slack and the other connectors |
| See what happens while an agent answers a prompt | Session Lifecycle |
| Build a chat UI | React SDK |
| Secure and deploy agents | Security Model, then Deploy |