Human-in-the-loop on Mobile

Approve tool calls from an approval sheet, cancel a run mid-stream, and queue prompts safely.

The agent proposes a tool; the phone asks; the user decides.

ts
await controller.decide(approvalId, "allow");
await controller.stop();
Diagram

Allow

Deny

🧠 Agent proposes tool

📱 Approval sheet

▶️ Tool runs

⏭️ Tool skipped

Quick Start

  1. Answer an approval
    ts
    await controller.decide(approvalId, "allow");

    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
    ts
    await controller.stop();

    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.

ChoiceEffect
allowRun the tool this once.
alwaysRun it and stop asking for this tool.
denySkip the tool.

The decision is sent back with approvalId. callId only says which tool row to attach the prompt to.


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.

ts
// setChat now also resets the turn itself, not only the approvals table.
turn = initialTurn;
approvals = emptyApprovals;

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.

Diagram

turn 1 begins

🧠 Turn 0 approvals

🧹 approvals = empty

📱 Turn 1: fresh table

🔀 setChat next

Before the turn reset, New chat cleared only the screen — the stale approval reappeared on the next Send.

Diagram
Approval sheetPhone screenUserApproval sheetPhone screenUsersees “run rm -rf /” prompttaps New chat (abandons it)blank chattypes something unrelated, Sendold “rm -rf /” prompt reappears with live buttonsAllow — posts the decision to the engine

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.

Diagram

choose

acknowledge

reject

acknowledge (no-op)

reject (no-op)

pending

sending

sent

failed

RuleBehaviour
acknowledge on a failed decisionStays failed. Only sending is promoted to sent.
isActionable for a failed decisionRemains true, so the user can retry.
acknowledge on a sending decisionPromoted to sent. This is the only transition into sent.
reject on a sent decisionStays sent. Only sending is promoted to failed.

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.


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.

Diagram
EngineUserEngineUsertool_call (callId=A, rm)tool_call (callId=B, curl)approval_request (approvalId=2 → callId=B)approval_request (approvalId=1 → callId=A)decide(approvalId=2, “allow”)decide(approvalId=1, “deny”)

Cancellation & Queued Prompts

runId arrives on start and is the only handle cancel accepts.

ts
const stopped = await controller.stop();

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.

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.

Diagram
EngineAdapterControllerUserEngineAdapterControllerUserstop()capture the live runcancel(r1)cancelledabort signalfetch cancelssend(“second”)

If you switch apps or lock the phone mid-answer, the run is cancelled — you won’t come back to a burning credit meter.

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.

ts
// controller.ts — who aborted the stream decides the outcome.
turn = abort.signal.aborted
  ? apply(turn, { type: "cancelled", msgId: turn.msgId ?? "unknown", runId })
  : apply(turn, { type: "error", msgId: turn.msgId ?? "unknown", kind: "transport", message });
Diagram

our abort

stream died

💥 stream throws in catch

🔍 abort.signal.aborted?

🛑 cancelled — grey Stopped

🌐 transport — 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.

Diagram
EngineControllerStop buttonBackgroundEngineControllerStop buttonBackgroundstop()cancel(r1)stop()same in-flight promisecancelledtruetrue

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.

ts
// controller.ts — abort ONLY when the engine confirms.
const stopped = await deps.engine.cancel(run.runId);
if (stopped) {
  run.abort.abort();          // accepted: idempotent from here
} else {
  run.cancelling = undefined; // refused: retry re-asks the engine
}
return stopped;
Diagram
EngineControllerUserEngineControllerUserstop()cancel(r1)false (refused)stopRefused notificationstop() againcancel(r1)trueno notification (success)

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.

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.

Diagram
EngineControllerUserEngineControllerUserstop()cancel(runId)false (not live)stopRefused notification

Best Practices

Never derive an approval from a row position

Position holds only while exactly one approval is outstanding; the moment a second appears it silently authorises the wrong command.

Cancel by runId only

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.

A decision the engine did not accept is false

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.

A new turn starts a new approval table

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.

An accepted stop is safe to call twice

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 refused stop is safe to retry

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.

Overlapping stops share one cancel

The background dispose and the Stop button both report the real outcome, so a real cancellation is never surfaced as a refusal.

Distinguish cancelled from transport by who aborted the stream

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).

Stop and flush on background

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.

A pending decision disables the row

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.

An acknowledged decision records the choice the user actually made

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.

Break out of the iterator to release the socket

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.

Before #4579, a refused stop or decision read as success

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".

Approval and run ids are URL-encoded, not pasted

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.


Related