PAIPAI

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.

On this page