How the Copilot CLI processes a user message end-to-end: from prompt to session.idle.
graph LR
App["Your App"] -->|send prompt| SDK["SDK Session"]
SDK -->|JSON-RPC| CLI["Copilot CLI"]
CLI -->|API calls| LLM["LLM"]
LLM -->|response| CLI
CLI -->|events| SDK
SDK -->|events| App
The SDK is a transport layer—it sends your prompt to the Copilot CLI over JSON-RPC and surfaces events back to your app. The CLI is the orchestrator that runs the agentic tool-use loop, making one or more LLM API calls until the task is done.
When you call session.send({ prompt }), the CLI enters a loop:
flowchart TD
A["User prompt"] --> B["LLM API call\n(= one turn)"]
B --> C{"toolRequests\nin response?"}
C -->|Yes| D["Execute tools\nCollect results"]
D -->|"Results fed back\nas next turn input"| B
C -->|No| E["Final text\nresponse"]
E --> F(["session.idle"])
style B fill:#1a1a2e,stroke:#58a6ff,color:#c9d1d9
style D fill:#1a1a2e,stroke:#3fb950,color:#c9d1d9
style F fill:#0d1117,stroke:#f0883e,color:#f0883e
The model sees the full conversation history on each call—system prompt, user message, and all prior tool calls and results.
Key insight: Each iteration of this loop is exactly one LLM API call, visible as one assistant.turn_start / assistant.turn_end pair in the event log. The loop makes no hidden calls; for model calls the runtime makes outside it, see Counting LLM calls.
Session creation and resume accept allowMultipleToolCalls in TypeScript and Java,
AllowMultipleToolCalls in Go and .NET, and allow_multiple_tool_calls in Python
and Rust. Omitted or true preserves the default: the model can request multiple
tools in one response and the runtime can execute their handlers concurrently.
Set it to false to ask the provider for at most one tool call per response.
The runtime sends OpenAI's parallel_tool_calls: false or Anthropic's
disable_parallel_tool_use: true. This is a provider hint, not an execution
guarantee; some compatible providers do not support it. If the provider returns
multiple calls anyway, the runtime handles all of them using its existing
execution policy. It does not discard calls, serialize handlers, or rewrite
system instructions. Applications that require serialized access to a resource
must coordinate that access in their handlers.
Subagents inherit the preference. Omission on a resident resume preserves the current value; after a cold resume, supply it again. Changing it on resume can invalidate the provider's prompt cache. There is no separate runtime execution mode or live-update option.
Custom tools can declare an optional idempotent hint, mirroring MCP's
idempotentHint: repeating a call with the same arguments has no additional
effect beyond the first call. A tool with no side effects is idempotent, so a
read-only tool should set it too. The runtime's programmatic tool calling uses
it to decide whether a call whose outcome was lost can be safely repeated. It
does not affect permission checks. Leave it unset unless it is true.
| SDK | Idempotent |
|---|---|
| Node.js | idempotent |
| Python | idempotent |
| Go | Idempotent |
| .NET | CopilotToolOptions.Idempotent |
| Java | ToolDefinition.idempotent(true) or @CopilotTool(idempotent = true) |
| Rust | with_idempotent(true) |
A turn is a single LLM API call and its consequences:
- The CLI sends the conversation history to the LLM
- The LLM responds (possibly with tool requests)
- If tools were requested, the CLI executes them
assistant.turn_endis emitted
A single user message typically results in multiple turns. For example, a question like "how does X work in this codebase?" might produce:
| Turn | What the model does | toolRequests? |
|---|---|---|
| 1 | Calls grep and glob to search the codebase |
✅ Yes |
| 2 | Reads specific files based on search results | ✅ Yes |
| 3 | Reads more files for deeper context | ✅ Yes |
| 4 | Produces the final text answer | ❌ No → loop ends |
The model decides on each turn whether to request more tools or produce a final answer. Each call sees the full accumulated context (all prior tool calls and results), so it can make an informed decision about whether it has enough information.
flowchart TD
send["session.send({ prompt: "Fix the bug in auth.ts" })"]
subgraph Turn1 ["Turn 1"]
t1s["assistant.turn_start"]
t1m["assistant.message (toolRequests)"]
t1ts["tool.execution_start (read_file)"]
t1tc["tool.execution_complete"]
t1e["assistant.turn_end"]
t1s --> t1m --> t1ts --> t1tc --> t1e
end
subgraph Turn2 ["Turn 2 — auto-triggered by CLI"]
t2s["assistant.turn_start"]
t2m["assistant.message (toolRequests)"]
t2ts["tool.execution_start (edit_file)"]
t2tc["tool.execution_complete"]
t2e["assistant.turn_end"]
t2s --> t2m --> t2ts --> t2tc --> t2e
end
subgraph Turn3 ["Turn 3"]
t3s["assistant.turn_start"]
t3m["assistant.message (no toolRequests)\n"Done, here's what I changed""]
t3e["assistant.turn_end"]
t3s --> t3m --> t3e
end
idle(["session.idle — ready for next message"])
send --> Turn1 --> Turn2 --> Turn3 --> idle
| Actor | Responsibility |
|---|---|
| Your app | Sends the initial prompt via session.send() |
| Copilot CLI | Runs the tool-use loop—executes tools and feeds results back to the LLM for the next turn |
| LLM | Decides whether to request tools (continue looping) or produce a final response (stop) |
| SDK | Passes events through; does not control the loop |
The CLI is purely mechanical: "model asked for tools → execute → call model again." The model is the decision-maker for when to stop.
These are two different completion signals with very different guarantees:
- Always emitted when the tool-use loop ends
- Ephemeral: not persisted to disk, not replayed on session resume
- Means: "the agent has stopped processing and is ready for the next message"
- Use this as your reliable "done" signal
The SDK's sendAndWait() method waits for this event:
// Blocks until session.idle fires
const response = await session.sendAndWait({ prompt: "Fix the bug" });- Optionally emitted: requires the model to explicitly signal it
- Persisted: saved to the session event log on disk
- Means: "the agent considers the overall task fulfilled"
- Carries an optional
summaryfield
session.on("session.task_complete", (event) => {
console.log("Task done:", event.data.summary);
});In autopilot mode (headless/autonomous operation), the CLI actively tracks whether the model has called task_complete. If the tool-use loop ends without it, the CLI injects a synthetic user message nudging the model:
"You have not yet marked the task as complete using the task_complete tool. If you were planning changes the user asked for, stop planning and start implementing. You aren't done until you have fully completed the task."
This effectively restarts the tool-use loop—the model sees the nudge as a new user message and continues working. The nudge also instructs the model not to call task_complete prematurely:
- Don't call it if you have open questions—make decisions and keep working
- Don't call it if you hit an error—try to resolve it
- Don't call it if there are remaining steps—complete them first
The one exception is a request that only asked for a review, analysis or recommendation: once that has been delivered, the nudge tells the model to call task_complete rather than start a follow-up it offered.
This creates a two-level completion mechanism in autopilot:
- The model calls
task_completewith a summary → CLI emitssession.task_complete→ done - The model stops without calling it → CLI nudges → model continues or calls
task_complete
Completing or pausing an autopilot objective leaves the session in autopilot mode by default, matching the interactive CLI. The objective still completes or pauses, and the run can become idle; keeping the mode selected does not keep working on a finished objective. To opt into automatic restoration to interactive mode, call session.rpc.autopilotObjective.setStayInAutopilot({ value: false }). To switch mode immediately, call session.rpc.mode.set({ mode: "interactive" }).
In interactive mode (normal chat), the CLI does not nudge for task_complete. The model may skip it entirely. Common reasons:
- Conversational Q&A: The model answers a question and simply stops—there's no discrete "task" to complete
- Model discretion: The model produces a final text response without calling the task-complete signal
- Interrupted sessions: The session ends before the model reaches a completion point
The CLI emits session.idle regardless, because it's a mechanical signal (the loop ended), not a semantic one (the model thinks it's done).
| Use case | Signal |
|---|---|
| "Wait for the agent to finish processing" | session.idle ✅ |
| "Know when a coding task is done" | session.task_complete (best-effort) |
| "Timeout/error handling" | session.idle + session.error ✅ |
Each assistant.turn_start / assistant.turn_end pair in the event log is one LLM API call made by the tool-use loop, and the loop makes no hidden calls for planning, evaluation, or completion checking. Model calls the runtime makes outside the loop don't get a turn pair, so the number of pairs is not always the total number of LLM API calls. Compaction is one such call: when the runtime summarizes the conversation history, the summarizer's call emits no turn events and reports its token usage in the compactionTokensUsed field of session.compaction_complete.
To inspect turn count for a session:
const turns = (await session.getEvents()).filter((event) => event.type === "assistant.turn_start").length;- Streaming Events Reference: Field-level reference for the event types applications most often handle
- Session Persistence: How sessions are saved and resumed
- Hooks: Intercepting events in the loop (permissions, tools)