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.

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 or the engine refused. 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. If you switch apps or lock the phone mid-answer, the run is cancelled — you won’t come back to a burning credit meter.

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 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.
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 — a Stop button that confirms a cancellation that never happened is worse than one that reports it could not.
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 11 Events

Where approval_request and cancelled are defined.

Agent Engine Port

The decide and cancel methods.