Tool Patterns

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_question prompts
  • showing pending human work while the agent run is paused
  • routing human decisions through your own auth, audit, or queue system

Architecture Shape

LayerResponsibility
tool policydeclares when a tool call needs approval
runner or Studiocreates approval and question events while the run waits
useChattracks pending approvals and questions from stream events
UIrenders pending items and disables duplicate submissions
backendauthorizes, 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

NeedUse
operator must allow or block a tool calltool approval policy
operator must provide missing informationask_question tool
operator decision must be audited before a writeapproval backend
model needs a value to continue reasoningquestion 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 decidingApprovals and answeringQuestions.
  • Test rejected approvals do not execute the side-effect tool.
  • Test pending items survive unrelated stream events and disappear after result events.