Pending Actions In React
Render typed suspend/resume, human input, and other blocked work.
Pending actions are controller sidecars for native tool parts. They are not a
serialized field on ThreadState or PaiMessage.
The common path is a tool renderer. Its action prop is already resolved and
typed for that tool, while part remains the authoritative native SDK value:
import type { ToolRenderProps } from "@pai/react";
import type { AssistantAgentContract } from "../../server/assistant-agent";
type ApprovalTool = ToolRenderProps<
AssistantAgentContract,
"requestApproval"
>;
function RequestApprovalRenderer({ tool }: { tool: ApprovalTool }) {
if (
tool.action === null ||
tool.part.state !== "input-available" ||
!tool.action
) {
return <p>Approval status: {tool.part.state}</p>;
}
return (
<ApprovalAction
action={tool.action}
summary={tool.part.input.summary}
/>
);
}
function ApprovalAction({
action,
summary,
}: {
action: NonNullable<ApprovalTool["action"]>;
summary: string;
}) {
const controller = AssistantAI.useAction(action);
return (
<form
onSubmit={(event) => {
event.preventDefault();
void controller.submit({ approved: true });
}}
>
<p>{summary}</p>
<p>{action.input.prompt}</p>
<button type="submit" disabled={controller.isPending}>
Approve
</button>
<button
type="button"
disabled={controller.isPending}
onClick={() => void controller.submit({ approved: false })}
>
Reject
</button>
</form>
);
}Register the renderer statically on the binding or dynamically in a component:
const AssistantAI = Pai.agent("main", {
toolRenderers: {
requestApproval: (tool) => <RequestApprovalRenderer tool={tool} />,
},
});useAction() adds local submission state around the same submit(),
cancel(), and fail() commands exposed by non-React clients. Keep it in a
child component that is mounted only when an action exists so hook ordering
remains stable.
Actions Outside Tool Renderers
For a global approval tray or automation bridge, scan the one public message collection and resolve each action from its exact message/tool pair:
import { isPaiToolPart } from "@pai/react";
function PendingActionTray() {
const chat = AssistantAI.useChat();
const actions = chat.messages.flatMap((message) =>
message.parts.flatMap((part) => {
if (!isPaiToolPart(part)) return [];
const action = chat.getPendingAction(message, part);
return action ? [action] : [];
}),
);
return (
<aside>
{actions.map((action) => (
<p key={action.ref.actionId}>{action.name}</p>
))}
</aside>
);
}The general controller method returns the contract-erased action view because a runtime message can contain any declared or dynamic tool. Prefer renderer props when UI needs a statically typed input and resume payload. Use the global scan when discovery across the whole transcript is the goal.