PAIPAI

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.

On this page