Human Input UI
Render tool approvals and agent questions in a custom React app.
Human input is a product workflow, not just a stream event. Use the React humanInput API to keep approval and question state close to your chat UI, while keeping the actual decision source in your backend.
Scenario
A support agent can issue refunds and ask the operator for missing ticket details. Studio can render those interactions for you. In a product UI, your app needs to show the same pending approvals and questions, submit the operator response, and avoid duplicate decisions.
When to Use It
Use this pattern when you are building a custom frontend for:
- approving or rejecting protected tool calls
- answering
ask_questionprompts - showing pending human work while the agent run is paused
- routing human decisions through your own auth, audit, or queue system
Architecture Shape
| Layer | Responsibility |
|---|---|
| tool policy | declares when a tool call needs approval |
| runner or Studio | creates approval and question events while the run waits |
useChat | tracks pending approvals and questions from stream events |
| UI | renders pending items and disables duplicate submissions |
| backend | authorizes, persists, audits, and resumes the paused work |
Hook Setup
Enable humanInput on the chat hook. The default event mappers understand Studio-compatible events:
{"type":"tool_approval_request","approval":{"id":"approval_123","toolName":"issue_refund","status":"pending"}}
{"type":"tool_question_request","question":{"id":"question_123","toolName":"ask_question","status":"pending","questions":[]}}import { useChat } from "@anvia/react";
export function SupportChat() {
const chat = useChat({
endpoint: "/api/support/runs",
humanInput: {
endpoint: "/api/support/human-input",
},
});
return (
<main>
<ApprovalQueue chat={chat} />
<QuestionQueue chat={chat} />
<ChatTranscript chat={chat} />
</main>
);
}endpoint on useChat is the streaming run endpoint. humanInput.endpoint is the submit endpoint for human decisions. With the default submit behavior, approvals post to /api/support/human-input/approvals/:id/decision, and question answers post to /api/support/human-input/questions/:id/answer.
Approval UI
Render from humanInput.approvals.pending and submit through approveTool(...) or rejectTool(...).
import type { UseChatResult } from "@anvia/react";
function ApprovalQueue({ chat }: { chat: UseChatResult }) {
return (
<section>
{chat.humanInput.approvals.pending.map((approval) => {
const busy = chat.decidingApprovals.has(approval.id);
return (
<article key={approval.id}>
<h3>{approval.toolName}</h3>
{approval.reason ? <p>{approval.reason}</p> : null}
{approval.args ? <pre>{approval.args}</pre> : null}
<button disabled={busy} onClick={() => chat.approveTool(approval.id)}>
Approve
</button>
<button
disabled={busy}
onClick={() => chat.rejectTool(approval.id, "Rejected by operator")}
>
Reject
</button>
</article>
);
})}
</section>
);
}Use decidingApprovals to disable duplicate clicks while the submit request is in flight. If the server returns an updated approval object, the hook merges it into humanInput.approvals.all and removes it from pending when the status changes.
Question UI
Render from humanInput.questions.pending and submit answers through answerToolQuestion(...).
import type { ToolQuestionAnswer, UseChatResult } from "@anvia/react";
function QuestionQueue({ chat }: { chat: UseChatResult }) {
return (
<section>
{chat.humanInput.questions.pending.map((question) => {
const busy = chat.answeringQuestions.has(question.id);
return (
<article key={question.id}>
{question.questions.map((prompt) => (
<fieldset key={prompt.id} disabled={busy}>
<legend>{prompt.question}</legend>
{prompt.choices.map((choice) => (
<button
key={choice.value}
onClick={() => {
const answers: ToolQuestionAnswer[] = [
{
questionId: prompt.id,
answer: choice.label,
choice: choice.value,
},
];
void chat.answerToolQuestion(question.id, answers);
}}
>
{choice.label}
</button>
))}
</fieldset>
))}
</article>
);
})}
</section>
);
}For multiple prompts in one ask_question call, collect one answer per prompt and submit them in a single answerToolQuestion(...) call. Use answeringQuestions to keep each pending question stable while the answer is being submitted.
Custom Backends
If your backend does not expose the default routes, provide submit handlers instead of humanInput.endpoint.
const chat = useChat({
endpoint: "/api/support/runs",
humanInput: {
decideApproval: async ({ approvalId, approved, reason }) => {
const response = await fetch(`/api/reviews/${approvalId}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ approved, reason }),
});
return response.json();
},
answerQuestion: async ({ questionId, answers }) => {
const response = await fetch(`/api/questions/${questionId}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ answers }),
});
return response.json();
},
},
});If your stream event names differ from Studio, keep the same UI API and map your events into ToolApproval and ToolQuestion.
const chat = useChat({
transport,
humanInput: {
eventToApproval(event) {
return event.kind === "approval.pending" ? event.payload : undefined;
},
eventToQuestion(event) {
return event.kind === "question.pending" ? event.payload : undefined;
},
decideApproval,
answerQuestion,
},
});Approval vs Question
| Need | Use |
|---|---|
| operator must allow or block a tool call | tool approval policy |
| operator must provide missing information | ask_question tool |
| operator decision must be audited before a write | approval backend |
| model needs a value to continue reasoning | question answer |
Do not overload approvals to collect missing data. An approval is a yes-or-no gate for a proposed action. A question is structured input that becomes the tool result and gives the model new information.
Test Checklist
- Test that approval and question request events appear in the UI.
- Test approve, reject, and answer submits call the expected backend route.
- Test duplicate clicks are blocked with
decidingApprovalsandansweringQuestions. - Test rejected approvals do not execute the side-effect tool.
- Test pending items survive unrelated stream events and disappear after result events.
