Tools
Human in the Loop
Make a tool wait for a person's approval before it acts.
A tool can refuse to act until a person approves. The check is in the tool, so 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-coding-agent";
import { actor, type Registry } from "rivetkit";
import { createClient } from "rivetkit/client";
export const deploy = defineTool({
name: "deploy",
label: "Deploy",
description: "Deploy a version to production. Every deploy needs a person's approval.",
parameters: Type.Object({ version: Type.String() }),
async execute(_toolCallId, { version }, signal, _onUpdate, ctx) {
const approvalKey = [ctx.sessionManager.getSessionId(), version];
if (!(await client.approval.getOrCreate(approvalKey).consume())) {
const text = `Waiting for a person to approve deploying ${version}.`;
return { content: [{ type: "text", text }], details: { version, approvalKey }, terminate: true };
}
await fetch(`https://deploy.example.com/releases/${version}`, { method: "POST", signal });
return { content: [{ type: "text", text: `Deployed ${version}.` }], details: { version } };
},
});
export const approval = actor({
state: { approved: false },
actions: {
approve: (c) => {
c.state.approved = true;
},
consume: (c) => {
const approved = c.state.approved;
c.state.approved = false;
return approved;
},
},
});
const client = createClient<Registry<{ approval: typeof approval }>>();
import { pi } from "@rivet-dev/pi";
import { setup } from "rivetkit";
import { approval, deploy } from "./deploy";
const agent = pi({ model: "anthropic/claude-opus-5-5", customTools: [deploy] });
export const registry = setup({ use: { agent, approval } });
import { createRivetKit } from "@rivetkit/react";
import { 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; approvalKey: string[] };
export function ApprovalDialog({ agentKey }: { agentKey: string[] }) {
const agent = useActor({ name: "agent", key: agentKey });
const [pending, setPending] = useState<Pending | null>(null);
agent.useEvent("event", (event) => {
if (event.type === "tool_execution_end" && event.toolName === "deploy") {
setPending(event.result.details.approvalKey ? event.result.details : null);
}
});
async function answer(approved: boolean) {
if (!pending) return;
if (approved) await client.approval.getOrCreate(pending.approvalKey).approve();
const text = approved ? `Approved. Deploy ${pending.version}.` : `Rejected. Don't deploy ${pending.version}.`;
setPending(null);
await agent.connection?.prompt(text, { streamingBehavior: "followUp" });
}
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>
);
}
- Each approval is an
approvalActor keyed by the session and the version.consumereturns whether it was approved and resets it, so one approval allows one deploy. - Without an approval, the tool returns
terminate: true, which ends the turn instead of letting the model try again. The agent can sleep while it waits. ApprovalDialogshows the request from thetool_execution_endevent. Approve callsapprove, then prompts the agent, which callsdeployagain.streamingBehavior: "followUp"queues the answer if the agent is still running.- Protect the
approvalActor with authentication so only approvers can callapprove.
See React SDK for the rest of a chat UI.