> ## Documentation Index
> Fetch the complete documentation index at: https://praison.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
await controller.decide(approvalId, "allow");
await controller.stop();
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph LR
    Agent[🧠 Agent proposes tool] --> Sheet[📱 Approval sheet]
    Sheet -->|Allow| Run[▶️ Tool runs]
    Sheet -->|Deny| Skip[⏭️ Tool skipped]

    classDef agent fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef sheet fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef run fill:#10B981,stroke:#7C90A0,color:#fff

    class Agent agent
    class Sheet sheet
    class Run,Skip run
```

## Quick Start

<Steps>
  <Step title="Answer an approval">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    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.
  </Step>

  <Step title="Cancel a live run">
    ```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
    await controller.stop();
    ```

    The run is cancelled by `runId`, delivered on the `start` event. `cancelled` is announced, never inferred from a stream that ends.
  </Step>
</Steps>

***

## Approval Choices

An `approval_request` carries `approvalId`, `callId`, `name`, and `args`.

| Choice   | Effect                                |
| -------- | ------------------------------------- |
| `allow`  | Run the tool this once.               |
| `always` | Run it and stop asking for this tool. |
| `deny`   | Skip the tool.                        |

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

<Info>
  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](https://github.com/MervinPraison/PraisonAI/pull/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.
</Info>

<Note>
  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.
</Note>

***

## 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    T0[🧠 Turn 0 approvals] -->|turn 1 begins| Clear[🧹 approvals = empty]
    Clear --> T1[📱 Turn 1: fresh table]
    Switch[🔀 setChat next] --> Clear

    classDef old fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef clear fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef new fill:#10B981,stroke:#7C90A0,color:#fff

    class T0,Switch old
    class Clear clear
    class T1 new
```

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Screen as Phone screen
    participant Sheet as Approval sheet
    User->>Sheet: sees "run rm -rf /" prompt
    User->>Screen: taps New chat (abandons it)
    Screen-->>User: blank chat
    User->>Screen: types something unrelated, Send
    Sheet-->>User: old "rm -rf /" prompt reappears with live buttons
    User->>Sheet: Allow — 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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
stateDiagram-v2
    [*] --> pending
    pending --> sending: choose
    sending --> sent: acknowledge
    sending --> failed: reject
    failed --> failed: acknowledge (no-op)
    sent --> sent: reject (no-op)

    classDef start fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef mid fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef done fill:#10B981,stroke:#7C90A0,color:#fff

    class pending start
    class sending mid
    class sent done
    class failed start
```

| Rule                                   | Behaviour                                                    |
| -------------------------------------- | ------------------------------------------------------------ |
| `acknowledge` on a `failed` decision   | Stays `failed`. Only `sending` is promoted to `sent`.        |
| `isActionable` for a `failed` decision | Remains `true`, so the user can retry.                       |
| `acknowledge` on a `sending` decision  | Promoted to `sent`. This is the only transition into `sent`. |
| `reject` on a `sent` decision          | Stays `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`.

<Warning>
  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.
</Warning>

<Note>
  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`.
</Note>

***

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Engine
    Engine-->>User: tool_call (callId=A, rm)
    Engine-->>User: tool_call (callId=B, curl)
    Engine-->>User: approval_request (approvalId=2 → callId=B)
    Engine-->>User: approval_request (approvalId=1 → callId=A)
    User->>Engine: decide(approvalId=2, "allow")
    User->>Engine: decide(approvalId=1, "deny")
```

<Warning>
  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.
</Warning>

***

## Cancellation & Queued Prompts

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

```ts theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
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.

<Note>
  **`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`.
</Note>

### 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](https://github.com/MervinPraison/PraisonAI/pull/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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Controller
    participant Adapter
    participant Engine
    User->>Controller: stop()
    Controller->>Controller: capture the live run
    Controller->>Engine: cancel(r1)
    Engine-->>Controller: cancelled
    Controller->>Adapter: abort signal
    Adapter->>Adapter: fetch cancels
    User->>Controller: send("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.

<Note>
  **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](https://github.com/MervinPraison/PraisonAI/pull/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](/docs/features/mobile/engines) for the engine-side detail.
</Note>

<Note>
  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](/docs/features/mobile/adapter-conformance).
</Note>

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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 });
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
graph TB
    Catch[💥 stream throws in catch] --> Ask{🔍 abort.signal.aborted?}
    Ask -->|our abort| Cancelled[🛑 cancelled — grey Stopped]
    Ask -->|stream died| Transport[🌐 transport — red Connection lost + Retry]

    classDef catch fill:#8B0000,stroke:#7C90A0,color:#fff
    classDef branch fill:#F59E0B,stroke:#7C90A0,color:#fff
    classDef cancelled fill:#6366F1,stroke:#7C90A0,color:#fff
    classDef transport fill:#10B981,stroke:#7C90A0,color:#fff

    class Catch catch
    class Ask branch
    class Cancelled cancelled
    class Transport transport
```

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.

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant Background
    participant Button as Stop button
    participant Controller
    participant Engine
    Background->>Controller: stop()
    Controller->>Engine: cancel(r1)
    Button->>Controller: stop()
    Controller-->>Button: same in-flight promise
    Engine-->>Controller: cancelled
    Controller-->>Background: true
    Controller-->>Button: true
```

### 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 theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
// 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;
```

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Controller
    participant Engine
    User->>Controller: stop()
    Controller->>Engine: cancel(r1)
    Engine-->>Controller: false (refused)
    Controller-->>User: stopRefused notification
    User->>Controller: stop() again
    Controller->>Engine: cancel(r1)
    Engine-->>Controller: true
    Controller-->>User: no 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.

<Note>
  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](#stop-is-idempotent-and-race-safe) above.
</Note>

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

```mermaid theme={"theme":{"light":"vitesse-light","dark":"vitesse-dark"}}
sequenceDiagram
    participant User
    participant Controller
    participant Engine
    User->>Controller: stop()
    Controller->>Engine: cancel(runId)
    Engine-->>Controller: false (not live)
    Controller-->>User: stopRefused notification
```

<Note>
  **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](/docs/features/mobile/overview#new-chat).
</Note>

<Note>
  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.
</Note>

***

## Best Practices

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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](#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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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**.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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).
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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 `break`s after the first delta. See [Stop is idempotent and race-safe](#stop-is-idempotent-and-race-safe).
  </Accordion>

  <Accordion title="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](https://github.com/MervinPraison/PraisonAI/pull/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"`.
  </Accordion>

  <Accordion title="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`.
  </Accordion>
</AccordionGroup>

***

## Related

<CardGroup cols={2}>
  <Card title="The 11 Events" icon="network-wired" href="/docs/features/mobile/protocol">
    Where approval\_request and cancelled are defined.
  </Card>

  <Card title="Agent Engine Port" icon="plug" href="/docs/features/mobile/engines">
    The decide and cancel methods.
  </Card>
</CardGroup>
