Call an Agent from Your Backend
Call an agent from your backend, scripts, or any JavaScript runtime, and stream its work.
The client is the rivetkit/client package. It calls actions on the agent and gets the agent’s events over a connection. A browser connects with a short-lived token from your backend.
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
import { contentText } from "@earendil-works/pi-ai";
import type { ConversationView, LiveState } from "@earendil-works/pi-durable";
import type { PiViewFrame } from "@rivet-dev/pi";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const client = createClient<typeof registry>();
const agent = client.agent.getOrCreate(["support", "customer-123"]);
const root = await agent.harness.root();
const { messages } = await agent.conversation.context(root.id);
console.log(`${messages.length} messages so far`);
const conn = agent.connect();
conn.onStatusChange((status) => console.log(`[connection ${status}]`));
let view: ConversationView | undefined;
// Operations that arrive before the watch call returns.
const early: Op[] = [];
// What is already printed.
let printed = "";
const shownTools = new Set<string>();
let retryAt: number | undefined;
const unsubscribe = conn.on("pi.view", (frame) => {
// RivetKit types event payloads as unknown, so name the frame's type.
const { value, ops = [] } = frame as PiViewFrame;
if (value) view = value;
else if (view) view = applyImmutable(view, ops);
else early.push(...ops);
if (view) show(view);
});
function show(current: ConversationView) {
const live = current.docs["pi.live"] as LiveState;
for (const tool of live.tools ?? []) {
if (shownTools.has(tool.callId)) continue;
shownTools.add(tool.callId);
console.log(`\n> ${tool.name}`);
}
const retry = live.generation?.retry;
if (retry && retry.at !== retryAt) console.log(`\nRetrying: ${retry.error}`);
retryAt = retry?.at;
// The answer in progress, or the newest entry once it is finished.
const last = current.entries.at(-1)?.model?.[0];
const answer = live.generation?.message ?? (last?.role === "assistant" ? last : undefined);
const text = answer ? contentText(answer.content, "") : "";
if (!text.startsWith(printed)) printed = "";
process.stdout.write(text.slice(printed.length));
printed = text;
}
// Sends this conversation's view to this connection.
const { value } = await conn.conversation.watch(root.id);
view = applyImmutable(value, early);
show(view);
const result = await conn.prompt("Clone https://github.com/honojs/hono and summarize its README.", {
requestId: crypto.randomUUID(),
});
if (result.status === "unanswered") console.error(`\nNo answer: ${result.reason}`);
unsubscribe();
await conn.dispose();
import { createRegistry } from "@earendil-works/pi-durable";
import { CodingTools } from "@earendil-works/pi-durable/tools";
import { pi } from "@rivet-dev/pi";
import { e2bProvider } from "@rivet-dev/sandbox-adapter/e2b";
import { setup } from "rivetkit";
const extensions = createRegistry();
extensions.install(CodingTools);
const agent = pi({
model: "anthropic/claude-opus-5-5",
registry: extensions,
sandbox: e2bProvider(),
});
export const registry = setup({ use: { agent } });
registry.start();
Handles and connections
- With no options,
createClient()readsRIVET_ENDPOINT,RIVET_NAMESPACE, andRIVET_TOKENfrom the environment, and defaults to a local Rivet atlocalhost:6420. - The
getOrCreatemethod returns a handle to the agent with that key, and creates the agent on first use. Actions called on the handle, likeconversation.context, are single requests. - An agent starts with one root conversation. The
harness.root()action returns its id, andpromptuses it unless you pass aconversationId. - Calling
connect()opens a live connection that can receive events. Actions called before it opens wait until it does. - A dropped connection reconnects on its own. The
onStatusChangecallback reportsconnecting,connected, anddisconnected, andidleafterdispose()closes the connection for good.
Events
Events go only to a connection that asks for them. Call conversation.watch(id) on the connection. It returns the conversation’s view as { value }, including any answer in progress, and then the connection receives pi.view frames as the agent works.
- Each frame after the first value has Chord operations in
ops. Apply them withapplyImmutablefrom@earendil-works/chord/delta, which you install withnpm add @earendil-works/chord. - The view holds the newest entries of the transcript. Read older ones with
conversation.entries. - The
seqnumber goes up by one for each frame. If a number is missing, callconversation.watchagain to get a new view. - To stop listening, call the function that
onreturns, orconversation.unwatch(id).
See Stream a conversation for what the view holds.
Results and errors
The prompt action resolves with { status, text } when the run ends, and its requestId and whenBusy options decide what a resent prompt or a prompt during a run does. See Call the agent.
- The
promptaction rejects with anActorErrorwhen the action itself fails. For example, the run passes the ten-minute action timeout (action_timed_out). A timeout only stops the wait. - To start a run without waiting for it, call
conversation.submit(id, { type: "input", content }). Follow its progress withconversation.watch.
Browsers
A browser shouldn’t hold your Rivet credentials. Your backend checks who the user is, then mints a short-lived token that reaches only their agent.
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
async function fetchAgentToken(): Promise<{ agentId: string; token: string }> {
const response = await fetch("/agent-token", { method: "POST", cache: "no-store" });
if (!response.ok) throw new Error("Could not get an agent token.");
return (await response.json()) as { agentId: string; token: string };
}
const { agentId } = await fetchAgentToken();
const client = createClient<typeof registry>({
endpoint: "https://api.rivet.dev",
namespace: "production",
getToken: async () => (await fetchAgentToken()).token,
});
const conn = client.agent.getForId(agentId).connect();
const result = await conn.prompt("Clone https://github.com/honojs/hono and summarize its README.");
console.log(result.status === "done" ? result.text : `No answer: ${result.reason}`);
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const admin = createClient<typeof registry>();
// Replace with your own auth, such as verifying a session cookie. Never trust a user id the client sends.
async function authenticateUser(request: Request): Promise<string | null> {
return request.headers.get("x-user-id");
}
const app = new Hono();
app.post("/agent-token", async (c) => {
const userId = await authenticateUser(c.req.raw);
if (!userId) return c.json({ error: "unauthorized" }, 401);
const agent = admin.agent.getOrCreate(["support", userId]);
const { token } = await agent.issueToken({ subject: userId, expiresIn: 900 });
return c.json({ agentId: await agent.resolve(), token }, 200, { "Cache-Control": "no-store" });
});
export default app;
- In
token.ts, your backend resolves the user’s agent and callsissueToken, which returns a token for that one agent that expires in 15 minutes. - In
browser.ts, the client connects withgetForId, andgetTokenfetches a new token whenever the old one expires, so the connection stays up.
See JWTs and Authentication.
The same actions work over HTTP. See the curl tab in the Quickstart.
See the client documentation in the Actors docs for everything rivetkit/client can do.