Interactions

One contract for asking the user something, in the terminal or over your own protocol.

A tool sometimes needs an answer before it can finish. Writing that against a terminal dialog strands it in the terminal, so @introspection-ai/recipes/interactions gives one call that resolves in the terminal, over RPC, headless, and in a host that pauses a run and resumes it later.

extensions/ask.ts
import { Type } from "typebox";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { askUserQuestion } from "@introspection-ai/recipes/interactions";

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "ask_user_question",
    label: "Ask user",
    description: "Ask the user a clarifying question and wait for the answer.",
    parameters: Type.Object({
      question: Type.String(),
      options: Type.Optional(Type.Array(Type.String())),
    }),
    // Required: a host pause must not strand parallel tool calls.
    executionMode: "sequential",
    async execute(toolCallId, params, signal, _onUpdate, ctx) {
      return await askUserQuestion(
        { question: params.question, options: params.options },
        // The tool's own signal, not ctx.signal, so aborting the turn
        // dismisses the dialog.
        { toolCallId, ctx, signal },
      );
    },
  });
}

askUserApproval is the same shape for a yes or no. Both return one result envelope whose outcome.type is approved, revision_requested, declined, or awaiting_user. The module registers no tools of its own: your recipe owns the tool and calls the helper from inside it.

Where the question goes

  1. PI_ASK_USER_AUTO_APPROVE for headless and CI runs. Confirmations resolve approved, everything else declined.
  2. An interactive UI, meaning the built-in dialog walk in terminal and RPC modes.
  3. A pause-and-resume host, signalled by PI_INTERRUPT_RESUME.
  4. A fallback that states nothing was shown and tells the model to ask in its normal reply.

The first three are decided for you. Locally you write nothing: run pi --recipe . and the dialog appears. The fourth means a tool never hangs forever on a channel that cannot answer.

The interrupt contract

Case three is the one you implement. A run that cannot answer inline stops and hands your runtime a request to surface, then continues when you answer it. Set PI_INTERRUPT_RESUME and the tool returns a details.interrupt whose outcome is { type: "awaiting_user" } instead of blocking.

FieldMeaning
reasoninput_required, confirmation, tool_call, or another string
messageThe prompt to show a human
optionsSuggested answers, not a closed enum
displayOptional structured display copy
metadataRenderer or tool hints
expiresAtOptional auto-decline instant
outcome{ type: "awaiting_user" } while it is pending

Render that in your own surface, then resume the run with a single-question payload. { answer } for a question, { approved, feedback? } for a confirmation, and a decline is a resume with status cancelled and no payload.

host.ts
// Without this the tool blocks on a dialog instead of handing you
// an interrupt to render.
const handle = await createAgentSession({
  recipe,
  agentName,
  env: { ...runEnv, PI_INTERRUPT_RESUME: "1" },
});

// One interrupt at a time, which is why the tool must be sequential.
if (result.details?.interrupt?.outcome.type === "awaiting_user") {
  const { reason, message, options } = result.details.interrupt;

  // Your surface: a websocket frame, a Slack block, a queued task.
  const answer = await myUi.ask({ reason, message, options });

  await myRuns.resume(runId, { answer });
}

This is the same shape an AG-UI style event stream wants: a typed request out, a typed answer back, with the run durable in between. Implementing it once gives every recipe you deploy a working question, without any recipe knowing what your surface is.