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

<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"}}
// At the start of each turn AND inside setChat(next):
approvals = emptyApprovals;
```

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
```

***

## 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. 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()` 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.

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

### 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>
  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 — a Stop button that confirms a cancellation that never happened is worse than one that reports it could not.
  </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="Stop is safe to call twice">
    The second tap reports success; the engine is not asked again for a run it already cancelled.
  </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="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.
  </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>
