Asynchronous User Interactions
Handle approvals, questions, and execution waiting for user input within a Session
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 cancelled + Tool failedSubscribe and respond
const unsubscribe = session.subscribe((mutation) => {
if (mutation.variant !== "part" || mutation.part.type !== "tool") return;
const interaction = (mutation.part.interactions ?? []).find(
(item) => item.status === "pending",
);
if (!interaction) return;
render_interaction(interaction);
});
await session.respond({
interaction_id,
response: {
type: "approval",
outcome: "resolved",
payload: { 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-type
mismatch, throws an explicit error.
Query pending Interactions
const pending = await session.interactions();
for (const request of pending) {
if (request.type === "approval") render_approval(request);
else render_questions(request.payload.questions);
}Approval requests also carry a response_schema that describes the required response shape.
Shell approval and Tool approval share one default schema, exported as
SESSION_APPROVAL_RESPONSE_SCHEMA (it requires decision to be approved or denied).
Questions support text, single_select, and multi_select. Answers must cover every requested
question_id:
await session.respond({
interaction_id,
response: {
type: "question",
outcome: "resolved",
payload: { 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 } from "@downcity/agent";
import { Workspace } from "@downcity/city";
import { AskQuestionsTool } from "@downcity/agent/tools";
const agent = new Agent({
id: "repo-helper",
tools: {
ask_question: AskQuestionsTool,
},
});
const workspace = new Workspace({
id: "project",
path: "/path/to/project",
});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
An AgentTool that requires user permission requests approval inside its own execution through the injected tool call context:
import { define_agent_tool } from "@downcity/type";
import { z } from "zod";
const delete_file = define_agent_tool({
description: "Delete a specified file from the workspace.",
input_schema: z.object({
path: z.string(),
}),
execute: async ({ path }, context) => {
// The Agent injects the approval port; ask waits for the user, always-allow passes.
const decision = await context.interactions.approval.request({
turn_id: context.turn_id,
tool_call_id: context.tool_call_id,
tool_name: "delete_file",
payload: { operation: "tool", validated_input: { path } },
});
if (!decision.approved) {
return { success: false, error: "denied" };
}
await remove_workspace_file(path);
return { deleted: path };
},
});
const agent = new Agent({
id: "repo-helper",
model,
tools: { delete_file },
});
// The Session picks its Workspace at creation time.The approval policy belongs to the Tool, not the Agent. After the model produces a Tool Call, the
Agent validates its input against the schema and enters the Tool's own execute. The Tool creates
an Approval Interaction with operation: "tool" there and waits for session.respond(...).
Approval continues to the side effect; denial does not run it.
The kernel no longer holds a tool-level approval hook: the Tool decides which step needs a human
decision, and the Session decides how that decision is answered. The approval mode (ask /
always-allow) takes effect inside the approval entry, so the Tool never reads the mode itself.
Under always-allow no Interaction is created and the decision comes back approved.
interactions.request() is the generic primitive, and interactions.approval is the approval
entry. To start any interaction, supply only semantic fields; interaction_id, created_at and
source are filled in by the primitive:
const handle = await session.interactions.request({
type: "question",
turn_id,
tool_call_id,
payload: { questions },
});
const result = await handle.result; // the producer interprets the payloadAn approval request gives the UI the actual invocation data:
source.tool_nameandsource.tool_call_id: the Tool and call identity.payload.validated_input: structured input that passed schema validation.payload.tool_description: the stable capability description from the Tool definition, when present.payload.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",
},
});| Mode | Behavior |
|---|---|
ask | A host Shell request creates an approval Interaction. |
always-allow | Future 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_idas the stable card key. - For any source with
source.tool_call_id, link it with that Tool Call ID. - Keep a
waiting-userTool in a waiting state; do not synthesize running or success client-side. resolved,denied,cancelled, andfailedare terminal and cannot be answered again. Interactions never time out: they end when the user responds or when the Turn/Session closes.- 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.