Sessions

Asynchronous User Interactions

Handle approvals, questions, and execution waiting for user input within a Session

Asynchronous User Interactions

An Interaction means that the current Turn needs user participation. The first release supports approval and question through one lifecycle, query API, and response API.

An Interaction is an Assistant Message Part, not a top-level Message or a new Turn. For a Tool source, the Tool and Interaction change atomically in one Assistant revision:

create:  Tool ready -> waiting-user + Interaction pending
respond: Interaction resolved + Tool running / failed
close:   Interaction expired / cancelled + Tool failed

Subscribe and respond

const unsubscribe = session.subscribe((mutation) => {
  if (
    mutation.variant !== "part" ||
    mutation.type !== "interaction" ||
    mutation.part.status !== "pending"
  ) return;

  render_interaction(mutation.part);
});

await session.respond({
  interaction_id,
  response: {
    kind: "approval",
    decision: "approved",
  },
});

subscribe() only reports persisted state. respond() atomically commits the Interaction and related Tool before it resumes execution. A missing or terminal request, or a response-kind mismatch, throws an explicit error.

Query pending Interactions

const pending = await session.interactions();

for (const { request } of pending) {
  if (request.kind === "approval") render_approval(request);
  else render_questions(request.questions);
}

Questions support text, single_select, and multi_select. Answers must cover every requested question_id:

await session.respond({
  interaction_id,
  response: {
    kind: "question",
    answers: [
      { question_id: "region", value: "cn" },
      { question_id: "features", value: ["search", "files"] },
    ],
  },
});

Model-initiated questions

ask_question is optional and is not registered by Agent by default. Add it explicitly when the model needs this capability:

import { Agent, Workspace } from "@downcity/agent";
import { AskQuestionsTool } from "@downcity/agent/tools";

const agent = new Agent({
  id: "repo-helper",
  workspace: new Workspace({ path: "/path/to/project" }),
  tools: {
    ask_question: AskQuestionsTool,
  },
});

When missing information would materially change the outcome, the model returns a standard Tool Call:

{
  "title": "Choose a deployment region",
  "questions": [
    {
      "question": "Which region should receive the deployment?",
      "type": "single_select",
      "options": [
        { "value": "cn", "label": "China" },
        { "value": "us", "label": "United States" }
      ]
    }
  ]
}

Every item in questions must explicitly provide type: text, single_select, or multi_select. The Session generates question_id values automatically. Select questions must provide at least one option with both value and label; text questions do not need options.

The Session atomically commits the Tool and Question Interaction as waiting-user / pending. After the host submits all answers through session.respond(...), the Tool Result is:

{
  "status": "resolved",
  "answers": [{ "question_id": "question:generated-id", "value": "cn" }]
}

The model then continues in the next Step of the same Turn. A question written only as assistant text does not enter this lifecycle: it completes the current Turn, and the next user message starts a new Turn.

General Tool approvals

A Tool that requires user permission before execution declares it with the AI SDK's native needsApproval property:

import { tool } from "ai";
import { z } from "zod";

const delete_file = tool({
  description: "Delete a specified file from the workspace.",
  inputSchema: z.object({
    path: z.string(),
  }),
  needsApproval: true,
  execute: async ({ path }) => {
    await remove_workspace_file(path);
    return { deleted: path };
  },
});

const agent = new Agent({
  id: "repo-helper",
  workspace,
  model,
  tools: { delete_file },
});

The approval policy belongs to the Tool, not the Agent. After the model produces a Tool Call, the AI SDK validates its input against the schema. The Session then creates an Approval Interaction with operation: "tool" and waits for session.respond(...). Approval resumes the original Tool Call; denial never runs execute.

A general Tool approval request gives the UI the actual invocation data:

  • source.tool_name and source.tool_call_id: the Tool and call identity.
  • validated_input: structured input that passed schema validation.
  • tool_description: the stable capability description from the Tool definition, when present.
  • model_explanation: optional explanation generated by the model for this call; it is not a security signal.

The host does not configure an approval title or reason. The UI should present the Tool, validated input, and optional model explanation without replacing the actual input with a client-generated summary.

Shell approval mode

const current = await session.status();

await session.set({
  security: {
    approval_mode: "always-allow",
  },
});
ModeBehavior
askAn unrestricted Shell request creates an approval Interaction.
always-allowFuture Shell requests in this Session are approved automatically.

Approval mode is a Shell Adapter execution policy, not general Interaction state. It does not change the sandbox, affect other Sessions, or resolve already-pending Interactions.

set() accepts the configured value and appends one SessionCommand to the Session's ordered input queue. The current Provider request and Tool callbacks keep their captured policy; the new mode becomes effective at the next Session Step checkpoint. If the current Turn has no next Step, the command is applied before the next prompt.

Read the projection with status(): security.approval_mode is the configured value and security.effective_approval_mode is the execution value. When they differ, the update is still queued. After the Command commits, the Session persists a completed Session configuration updated Action.

Initialization or restoration can suppress that Action and its Mutation through the optional second argument to set(). The configuration is still written and committed at the checkpoint:

await session.set(
  { security: { approval_mode: "always-allow" } },
  { persist_action: false, publish_mutation: false },
);

Setting the same approval mode is idempotent and does not create another configuration Action. The approval mode is restored from Session metadata.

UI implementation details

  • Use interaction_id as the stable card key.
  • For source.type === "tool", link it with source.tool_call_id.
  • Keep a waiting-user Tool in a waiting state; do not synthesize running or success client-side.
  • resolved, expired, and cancelled are terminal and cannot be answered again.
  • Clear old Interaction UI when switching Sessions; never send an old Session's ID to a new one.

See Messages and Parts for complete Part states.