Tools
Human in the Loop
Make a tool wait for a person's approval before it acts.
Pi has no built-in approval flow, so the check goes in the tool. A tool can refuse to act until a person approves, and the model can’t skip it: deploy only runs after someone approves that exact deploy.
import { Type } from "@earendil-works/pi-ai";
import { defineTool } from "@earendil-works/pi-durable";
import { actor, type Registry } from "rivetkit";
import { createClient } from "rivetkit/client";
export const deploy = defineTool({
name: "deploy",
description:
"Deploy a version to production. Every deploy needs a person's approval: call it without approvalId to ask for one.",
parameters: Type.Object({ version: Type.String(), approvalId: Type.Optional(Type.String()) }),
execute: async ({ version, approvalId }, _api, context) => {
if (approvalId === undefined) {
const id = crypto.randomUUID();
await client.approval.getOrCreate([id]).request(version);
const text = `Waiting for a person to approve deploying ${version}. After approval, call deploy again with approvalId ${id}.`;
// Ends the run, so the agent waits for the person instead of trying again.
return { content: [{ type: "text", text }], details: { version, approvalId: id }, control: { terminate: true } };
}
if (!(await client.approval.get([approvalId]).consume(version))) {
throw new Error(`Deploying ${version} is not approved.`);
}
await fetch(`https://deploy.example.com/releases/${version}`, { method: "POST", signal: context.abortSignal });
return { content: [{ type: "text", text: `Deployed ${version}.` }] };
},
});
export const approval = actor({
state: { version: "", approved: false },
actions: {
request: (c, version: string) => {
c.state.version = version;
},
approve: (c) => {
c.state.approved = true;
},
consume: (c, version: string) => {
const approved = c.state.approved && c.state.version === version;
c.state.approved = false;
return approved;
},
},
});
const client = createClient<Registry<{ approval: typeof approval }>>();
import { createRegistry, defineExtension } from "@earendil-works/pi-durable";
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
import { approval, deploy } from "./deploy";
const extensions = createRegistry();
extensions.install(defineExtension({ name: "deploys", tools: [deploy] }));
const agent = pi({ model: "anthropic/claude-opus-5-5", registry: extensions });
export const registry = setup({ use: { agent, approval } });
registry.start();
import { createRivetKit } from "@rivetkit/react";
import { useEffect, useState } from "react";
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
const { useActor } = createRivetKit<typeof registry>();
const client = createClient<typeof registry>();
type Pending = { version: string; approvalId: string };
export function ApprovalDialog({ agentKey }: { agentKey: string[] }) {
const agent = useActor({ name: "agent", key: agentKey });
const [pending, setPending] = useState<Pending | null>(null);
// Watch the root conversation, so this connection receives its pi.events.
useEffect(() => {
const conn = agent.connection;
if (!conn) return;
void conn.harness.root().then((root) => conn.conversation.watchEvents(root.id));
}, [agent.connection]);
agent.useEvent("pi.events", ({ events }) => {
for (const event of events) {
if (event.type !== "tool_execution_end" || event.toolName !== "deploy") continue;
const result = event.entry?.model?.[0];
if (result?.role !== "toolResult") continue;
const details = result.details as Partial<Pending> | undefined;
if (details?.version && details.approvalId) {
setPending({ version: details.version, approvalId: details.approvalId });
}
}
});
async function answer(approved: boolean) {
if (!pending) return;
if (approved) await client.approval.get([pending.approvalId]).approve();
const text = approved ? `Approved. Deploy ${pending.version}.` : `Rejected. Don't deploy ${pending.version}.`;
setPending(null);
await agent.connection?.prompt(text);
}
if (!pending) return null;
return (
<div role="alertdialog">
<p>The agent wants to deploy {pending.version}.</p>
<button type="button" onClick={() => void answer(true)}>
Approve
</button>
<button type="button" onClick={() => void answer(false)}>
Reject
</button>
</div>
);
}
- The first
deploycall creates anapprovalActor with a random id for that version. The tool returnscontrol: { terminate: true }, which ends the run instead of letting the model try again. The agent can sleep while it waits. - The approval id is in the tool result, so the model passes it back on the next call. A made-up id fails, because no
approvalActor has it. - The
consumeaction returns whether that version was approved and resets it, so one approval allows one deploy. - The
ApprovalDialogcomponent watches the root conversation and shows the request from thetool_execution_endevent. Approve callsapprove, then prompts the agent, which callsdeployagain. - The
deploytool leavesreplayunset, so a deploy cut off by a crash isn’t sent twice. See Custom Tools. - Protect the
approvalActor with authentication so only approvers can callapprove.
See React SDK for the rest of a chat UI.