Human Approval
Pause a tool until a person answers, then continue the same call.
Some work cannot finish on the server. It needs a yes/no, a form, or a file from a person.
When that happens the tool suspends. PAI stores a pending action on the thread. The UI submits an answer. The tool starts again from that wait point.
tool runs
→ ctx.suspend("approve", { summary })
→ thread shows a pending action
→ person submits { approved: true }
→ tool resumes with that payloadRefreshing the page does not lose the wait. The thread is durable.
Define the wait
import { defineTool } from "@pai/core";
import { z } from "zod";
type Drafts = {
get: (id: string) => Promise<{ title: string; summary: string }>;
publish: (id: string) => Promise<void>;
};
export const publishPost = defineTool({
id: "publishPost",
description: "Publish a draft after the user approves it",
inputSchema: z.object({ draftId: z.string() }),
outputSchema: z.object({ published: z.boolean() }),
contextSchema: z.object({
drafts: z.custom<Drafts>(),
}),
suspend: {
approve: {
suspendSchema: z.object({ title: z.string(), summary: z.string() }),
resumeSchema: z.object({ approved: z.boolean() }),
},
},
execute: async (ctx) => {
if (ctx.entry.type === "initial") {
const draft = await ctx.context.drafts.get(ctx.input.draftId);
return ctx.suspend("approve", {
title: draft.title,
summary: draft.summary,
});
}
if (ctx.entry.type === "resume" && ctx.entry.name === "approve") {
if (!ctx.entry.resume.approved) return { published: false };
await ctx.context.drafts.publish(ctx.input.draftId);
return { published: true };
}
throw new Error("Unexpected tool entry");
},
});suspendSchema is what the UI renders. resumeSchema is what action.submit must send. A bad submit is rejected; the tool stays waiting.
Give the tool drafts through runtimeContext, the same way as Tools.
Use submit for yes and no. Use cancel only when the wait is abandoned with no payload.
Show it in React
The usual place to render a wait is a tool card. Assistant.Tool passes a typed action when the tool is waiting:
import type { ToolRenderProps } from "@pai/react";
import type { AssistantPai } from "../../server/http-server";
function PublishCard({
tool,
}: {
tool: ToolRenderProps<AssistantPai["agents"]["main"], "publishPost">;
}) {
const action = tool.action;
if (!action) return <p>Publish status: {tool.part.state}</p>;
return (
<div>
<p>{action.input.summary}</p>
<button type="button" onClick={() => void action.submit({ approved: true })}>
Publish
</button>
<button type="button" onClick={() => void action.submit({ approved: false })}>
Reject
</button>
</div>
);
}Register it:
export const Assistant = Pai.agent("main", {
toolRenderers: {
publishPost: (tool) => <PublishCard tool={tool} />,
},
});For a tray outside the transcript, scan messages with chat.getPendingAction(message, part).
Next
- Client tools if the wait is really "run this in the browser"
- React UI for chat, composers, and tool cards