Skip to main content
The agent proposes a tool; the phone asks; the user decides.

Quick Start

1

Answer an approval

decide resolves only once the engine has recorded the decision, so the UI never shows “Allowed” before the request landed. The moment you tap, the row’s buttons disable themselves, so a double tap on a shaky connection cannot send the same decision twice.
2

Cancel a live run

The run is cancelled by runId, delivered on the start event. cancelled is announced, never inferred from a stream that ends.

Approval Choices

An approval_request carries approvalId, callId, name, and args. The decision is sent back with approvalId. callId only says which tool row to attach the prompt to.
Both approvalId and runId are opaque strings, so remote-http passes them through encodeURIComponent before splicing them into the approve/cancel paths. An id containing /, #, or ? therefore still reaches the engine as one path segment — before #4589 the id was pasted verbatim, so ap/1?x=2#f posted the decision to a different path, the engine never saw it, and the run stayed blocked until its timeout while the UI reported the decision as sent.
If the decide call itself throws — a socket closed mid-send — the row renders as failed and carries the reason (e.g. "socket closed"). It is never quietly acknowledged as sent, because the engine may not have received the decision at all.

Approvals are per turn

The approval table is cleared at the start of every turn and again when setChat() switches conversations, so a new prompt is never confused with an answered one. An engine may number approvalId per run — the protocol only guarantees uniqueness within a single run. Reusing an id across turns once shadowed the new request behind the previous turn’s answered entry: turn 1’s prompt rendered as sent/allow with dead buttons, still carrying turn 0’s args. A user could be shown rm -rf / as already-allowed because an earlier ls reused the id.
Without the turn reset, send() publishes the stale turn before the new run starts, so New chat cleared the screen only until the user’s next message — and the previous chat’s pending approval reappeared with live buttons. A prompt to run rm -rf /, abandoned by starting a new chat, came back with live buttons on the next Send, and Allow posted the decision to the engine. The within-turn rule is unchanged: the same approvalId twice inside one run is still the engine repeating itself — one prompt, not two. Before the turn reset, New chat cleared only the screen — the stale approval reappeared on the next Send.

Refused decisions stay refused

A decision is sending until the engine confirms it, and only then sent. If the engine refuses the decision, acknowledge never promotes it. An already-confirmed decision is never un-confirmed by a late failure — a duplicated or delayed failure signal cannot flip an allowed-and-executed rm -rf / back into a live prompt. Pinned by "a decision the engine already CONFIRMED cannot be un-confirmed by a late failure" in core/src/run/approvals.test.ts.
Before this fix, a refused decision was marked “sent”: the buttons went dead, the outstanding count dropped to zero, and the run stayed blocked until its 300-second timeout — silence read as success. A refused decision now stays actionable so the user can answer again.
The decision lifecycle lives on the approval table, not on the turn. The transcript is rendered from both — buildTranscript(turn, approvals) — because a row built from the turn alone would stay pending forever: the user would tap Deny and the card would never acknowledge it. Pinned by "an approval row shows the decision after the user answers it" in app/src/main.test.ts, which drives an approval_request end-to-end, clicks Deny, and asserts data-state moves off pending.

The Two-Approval Case

When two approvals are outstanding, the prompts arrive in the opposite order to their tool rows. Routing by position authorises the wrong command.
Bind each prompt to its row by callId, and send the decision back with approvalId. Zipping the two lists by index crosses rm with curl — the exact bug the approvalId design exists to prevent.

Cancellation & Queued Prompts

runId arrives on start and is the only handle cancel accepts.
stop returns true when it cancelled a live run and false when there was nothing to cancel — including when the engine’s cancel POST returns any non-200 status. Stop cancels the network request too: the controller aborts the same signal it handed to fetch, so a request already in flight is dropped rather than left generating tokens after you tapped the button.
Stop cancels the run, not the chat. The controller passes the engine the runId it was handed on start, never the chatId. A cancel handed the wrong id returns false, the model keeps generating and billing, and the Stop button silently does nothing. Pinned by "Stop cancels the RUN, not the chat" in core/src/run/controller.test.ts, which asserts cancel receives the same id run was given and that it is not the chatId.

What the controller hands the engine

Every turn the controller assembles a RunRequest and hands it to the engine. Three of its fields are guarantees the user feels directly.
  • Attachments make the trip. A photo the user attaches to Send arrives on the engine’s RunRequest.attachments, not dropped. Pinned by "an attachment the user picked reaches the engine" in core/src/run/controller.test.ts.
  • Tools are requested, not silently off. The controller sets tools: true on the RunRequest, so a turn that needs a tool can call one. Pinned by "tools are requested, not silently disabled".
  • No ticker leaks past a finished turn. The pacing interval that ticks during a run is stopped in finally, so twenty messages do not leak twenty 16 ms timers on the phone. Pinned by "a finished turn leaves no timer running".
The same reader release covers New chat, switching apps, and the phone locking mid-answer: the consumer breaks out of the for await iterator, and the engine’s finally calls await reader.cancel(), so the model stops generating and the socket is released. Before #4579, only the request abort was covered — the response stream leaked and the model kept billing after you left. stop() captures the run when tapped. It no longer re-reads live after awaiting the engine, so tapping Stop just as the last token lands cannot throw a TypeError that blanks the whole screen when the queue is empty, nor cancel the queued follow-up instead of the finished run. If you switch apps or lock the phone mid-answer, the run is cancelled — you won’t come back to a burning credit meter.
Stop then immediately Send is safe. If the user taps Stop and the controller then hands the engine a fresh run whose AbortSignal is already down, the engine aborts before subscribing — no deltas stream, and nothing is persisted for the aborted run. Before #4581, the run streamed to completion and persisted an answer that had no consumer. The dispose-then-new-run path is the other route into the same already-aborted signal. See Mobile Engines for the engine-side detail.
The engine side of this contract — that aborting the signal actually stops the stream — is now pinned by a spawned break mode (abort_ignored) in the engines conformance suite. See Adapter Conformance.

A user-stopped turn reads as cancelled, not transport

The controller branches in the read-loop’s catch on abort.signal.aborted. If the abort was ours — tapping Stop cancels the engine, aborts the reader, and the underlying fetch errors the body stream — the turn ends cancelled with the grey “Stopped” notice. If the stream dies on its own — socket reset, mid-stream 5xx — the turn ends with a transport error and the red “Connection lost” retry.
The pair is deliberate: hiding a genuine network failure behind a calm “Stopped” removes the retry the user needs; painting a deliberate Stop as “Connection lost” tells a screen reader the wrong thing and offers a Retry button the user didn’t ask for.

Stop is idempotent and race-safe

Stop is safe to call more than once, and two overlapping stops share a single cancel. A second tap reports true, not a refusal: the controller short-circuits on an already-aborted run instead of asking the engine again. The background → dispose path calls stop() twice by design, so the second call must not read as a failure. Two stops that overlap — the background handler and the user’s Stop button, say — both receive the same in-flight promise. The engine is asked exactly once, and both callers get the true result. This removes the false “the engine did not accept the stop” notice that once fired for a stop that did happen. Idempotence never invents a cancellation: stop() still returns false when there was nothing to stop.

A refused stop is retryable

A false from the engine leaves the run alive and does not short-circuit the next tap. When the engine answers cancel(runId) === false, stop() clears run.cancelling and never calls run.abort.abort(). Two things follow: the reader stays attached to a run the engine says is still running, and aborted stays unset, so the next tap reaches the engine again rather than hitting the idempotence guard.
A second true reaches the user as silence — stopNotice defines an accepted stop as no notification. A second false shows the refusal again. Before this, the idempotence guard aborted the reader even on a refusal, so the retry short-circuited to true without asking the engine, and the run kept generating and billing while the user was told nothing.
The two guarantees are a pair: an accepted stop is idempotent (a repeat tap does not re-ask the engine), and a refused stop is retryable (a repeat tap does re-ask). See Stop is idempotent and race-safe above.

When Stop is refused

The app tells you when a stop did not land, instead of going quiet as though it worked. When the engine genuinely refuses a stop — controller.stop() returns false because the run was not live, or the underlying stop call rejects — the user sees a notification carrying the stopRefused string (“The engine did not accept the stop. It may still be running.”). A button that quietly confirms a cancellation that never happened is worse than one that reports it could not.
New chat is a stop. Tapping New chat calls controller.stop() before it clears the transcript, so starting a fresh conversation cancels any live run. See New chat semantics.
A cancelled turn is never persisted, so it has no end and no usage, yet it stays on screen — which is why its index cannot be computed client-side.

Best Practices

Position holds only while exactly one approval is outstanding; the moment a second appears it silently authorises the wrong command.
cancel returns false when the run was not live, and also when the engine’s cancel POST returns any non-200 status — a Stop button that confirms a cancellation that never happened is worse than one that reports it could not. The regression test "a decision or cancel the engine did NOT accept is reported as refused" iterates [202, 400, 401, 403, 500, 502] and asserts cancel returns false for each.
decide returns false for a decision the engine did not accept, including a non-200 response body — not only an unknown or already-decided id. The sending → sent promotion in Refused decisions stay refused depends on it: a lying true once promoted the row past sent, so this transport-layer check is the other half of that guarantee. The same test above asserts decide returns false across [202, 400, 401, 403, 500, 502], with "a 200 with an ok body is accepted, so the refusal test is not vacuous" as the companion.
The same approvalId on a later turn is a fresh request, never a repeat of the answered one. Switching chats also clears the pending prompts, so a different conversation cannot inherit the last one’s approvals.
Once the engine confirms, the run is aborted and a second tap short-circuits to true without re-asking. The guard applies only after the engine confirms.
A false from the engine does not set aborted, so the next tap reaches the engine again instead of short-circuiting. Retrying a refused stop actually re-asks; a second acceptance is silent, a second refusal shows the notice again.
The background dispose and the Stop button both report the real outcome, so a real cancellation is never surfaced as a refusal.
The engine’s goodbye frame usually loses the race against the reader’s abort, so cancelled used to be virtually unreachable. Reading abort.signal.aborted at catch time makes the outcome deterministic: a user-tapped Stop reads as cancelled (grey “Stopped”), and a stream that dies on its own reads as transport (red “Connection lost” with Retry).
Backgrounding stops the run loop and flushes the transcript, because iOS may kill the suspended app with no further callback. The lifecycle handler wired at boot calls controller.stop() the moment the app enters the background phase.
The approval buttons disable themselves as soon as a decision is in flight and stay disabled while it is pending — the row is drawn with disabled = !actionable. A user hitting Allow twice on a shaky connection cannot send two decisions for the same approval. The three buttons themselves must carry three distinct choices; every layer above the DOM routes a decision by approvalId, and the last hop decides what the decision is — so a bug that stamped "allow" onto every button would go unnoticed. Pinned by "each approval button carries its OWN choice" in app/src/dom.test.ts, which walks the row, asserts exactly three data-choice buttons whose values sort to ["allow", "always", "deny"], and checks that each button’s data-approval-id matches the row’s approval id.
acknowledge promotes sending → sent and writes the user’s own choice into the sent state — it is not permitted to substitute a fixed value. An acknowledged Deny is recorded as deny, not allow; the outstanding count, the audit trail, and the row the user reads back all agree with the button the user actually pressed. Pinned by "acknowledging records the choice the user actually made" in core/src/run/approvals.test.ts, which iterates ["allow", "always", "deny"] and asserts the sent.choice on each.
The consumer’s break — or a return, or a thrown exception — out of the for await loop is the cancellation channel; there is no separate close(). The engine’s finally calls await reader.cancel(), so leaving the stream releases the reader and the model stops billing. The regression test "leaving the stream early releases the socket" asserts the underlying ReadableStream’s cancel() fires when the consumer breaks after the first delta. See Stop is idempotent and race-safe.
The engine’s postOk did not check the status it received, so any non-200 — 202, 401, 500, 502 — reported success. As the survival note in #4579 puts it: “Every non-200 — 202, 401, 500, 502 — reports success. The UI announces a stop that never happened and marks an approval sent that the engine never received.” The fix adds if (response.status !== 200) return false;, pinned by "a decision or cancel the engine did NOT accept is reported as refused".
The protocol treats approvalId and runId as opaque, so an engine is free to hand back an id containing /, #, or ?. remote-http URL-encodes both before building the approve or cancel path so the id reaches the engine as a single segment. Pinned by "an approval id with URL-significant characters is encoded, not pasted" and "a cancel with an awkward run id is encoded too" in remote-http/engine.test.ts.

The 11 Events

Where approval_request and cancelled are defined.

Agent Engine Port

The decide and cancel methods.