Human Input UI Patterns
Pause a run until a user submits typed resume data from a tray or tool renderer.
This recipe shows the same suspend/resume action rendered in two UI patterns:
- a global tray above the composer, sidebar, or operator panel;
- a tool-owned renderer inside the conversation timeline.
The backend tool is the same in both cases. Human input is just a suspend/resume wait point.
// server/tools.ts
import { defineTool } from "@pai/core";
import { z } from "zod";
export const requestApproval = defineTool({
id: "requestApproval",
description: "Ask a user to approve a report",
inputSchema: z.object({
summary: z.string(),
}),
outputSchema: z.object({
approved: z.boolean(),
comment: z.string().optional(),
}),
suspend: {
approval: {
suspendSchema: z.object({
prompt: z.string(),
}),
resumeSchema: z.object({
approved: z.boolean(),
comment: z.string().optional(),
}),
},
},
execute: async (ctx) => {
if (ctx.entry.type === "initial") {
return ctx.suspend("approval", {
prompt: ctx.input.summary,
});
}
if (ctx.entry.type === "resume" && ctx.entry.name === "approval") {
return ctx.entry.resume;
}
throw new Error("Unsupported tool entry");
},
});// server/agent.ts
import { defineAgent } from "@pai/core";
import { requestApproval } from "./tools";
export const agent = defineAgent({
name: "report",
instructions: "Draft reports and request approval before sending.",
tools: {
requestApproval,
},
});// web/src/approval-tray.tsx
import { isPaiToolPart, type PendingActionView } from "@pai/react";
import { AssistantAI } from "./pai-react";
import type { AssistantAgentContract } from "../../server/agent";
function ApprovalTray() {
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?.name === "requestApproval.approval" ? [action] : [];
}),
);
return actions.map((action) => (
<ApprovalAction
key={`${action.origin.messageId}:${action.origin.toolCallId}:${action.ref.actionId}`}
action={action}
/>
));
}
function ApprovalAction({
action,
}: {
action: PendingActionView<AssistantAgentContract, "requestApproval.approval">;
}) {
const controller = AssistantAI.useAction(action);
return (
<form
onSubmit={(event) => {
event.preventDefault();
controller.submit({ approved: true });
}}
>
<p>{String(action.input.prompt)}</p>
<button type="submit" disabled={controller.isSubmitting}>
Approve
</button>
<button
type="button"
disabled={controller.isSubmitting}
onClick={() => controller.submit({ approved: false })}
>
Reject
</button>
{controller.error ? <span>{controller.error.message}</span> : null}
</form>
);
}The run continues after the resume payload is recorded on the waiting tool part.
Tool-Owned Renderer
Use a tool renderer when the action belongs visually inside the conversation timeline instead of a global tray.
// web/src/report-chat.tsx
import { isPaiToolPart } from "@pai/react";
import { AssistantAI } from "./pai-react";
function AssistantChat() {
const chat = AssistantAI.useChat();
const { composer } = chat;
AssistantAI.useToolRenderer(
"requestApproval",
({ part, runStatus, action }) => {
if (action) {
return (
<form
onSubmit={(event) => {
event.preventDefault();
action.submit({ approved: true });
}}
>
<p>{action.input.prompt}</p>
<button type="submit">Approve</button>
<button
type="button"
onClick={() => action.submit({ approved: false })}
>
Reject
</button>
</form>
);
}
if (part.state === "output-available") {
return <span>Approval answered</span>;
}
return <span>Waiting for approval</span>;
},
);
return (
<section>
{chat.messages.map((message) => (
<article key={message.id}>
{message.parts.map((part, index) => {
if (isPaiToolPart(part)) {
return (
<AssistantAI.Tool
key={part.toolCallId}
message={message}
part={part}
/>
);
}
if (part.type === "text") return part.text;
return <span key={index}>{part.type}</span>;
})}
</article>
))}
<form onSubmit={composer.submit}>
<textarea
value={composer.text}
onChange={(event) => composer.setText(event.currentTarget.value)}
/>
<button type="submit" disabled={!composer.canSubmit}>
Send
</button>
</form>
</section>
);
}useToolRenderer() receives the pending action sidecar linked to that tool part
while the tool is waiting. The sidecar is derived from the native tool part and
normalized control state; it is not stored on the message.
Use a global pending-action tray when approvals should appear above the composer, in a sidebar, or in a shared operator queue. Use a tool renderer when the tool owns the best place to continue.