The same assistant as the chat on the website, with the same conversations. A
conversation started on a phone is in the browser too, and the other way round.

Asking is two steps. The request stores the question and answers at once with
an empty answer whose `status` is `streaming`. A worker then writes the answer
and sends it over a websocket while it does. Nothing waits for the model inside
the request, so a slow answer never times out a phone.

## Which assistant

A token belongs to a person, and the person decides which chat they talk to.

| Who | Chat (`surface`) | What it knows |
| --- | --- | --- |
| A trader | `trader_chat` | Their own accounts, payouts, tickets and the firm's public knowledge |
| Somebody who helps run the firm: the owner, or staff with a role | `team_chat` | The admin screens their role may open |
| Our own staff, with `Propper-Workspace` naming a firm | `team_chat` | That firm, as its team sees it |

The team's chat needs the `use_ai_chat` permission on top, as it does on the
website. Without it every endpoint answers `403 forbidden` with
`required_permission`. Our own staff always name the firm with the
`Propper-Workspace` header (see [Signing in as a person](/docs/api/apps/overview)).
Without it, or with `platform`, every endpoint answers `400 workspace_required`.

A conversation belongs to the chat it was started in. A list only holds the
conversations of the chat the token talks to now.

## The endpoints

| Method | Path | Scope |
| --- | --- | --- |
| GET | [`/v1/me/ai`](/docs/api/apps/ai-assistant) | `ai:read` |
| GET | [`/v1/me/ai/conversations`](/docs/api/apps/ai-conversations) | `ai:read` |
| POST | [`/v1/me/ai/conversations`](/docs/api/apps/ai-conversation-create) | `ai:write` |
| GET | [`/v1/me/ai/conversations/{id}`](/docs/api/apps/ai-conversation) | `ai:read` |
| POST | [`/v1/me/ai/conversations/{id}/messages`](/docs/api/apps/ai-message-create) | `ai:write` |
| POST | [`/v1/me/ai/conversations/{id}/cancel`](/docs/api/apps/ai-cancel) | `ai:write` |
| PATCH | [`/v1/me/ai/conversations/{id}`](/docs/api/apps/ai-conversation-update) | `ai:write` |
| DELETE | [`/v1/me/ai/conversations/{id}`](/docs/api/apps/ai-conversation-delete) | `ai:write` |
| GET | [`/v1/me/ai/messages/{id}/attachments/{index}`](/docs/api/apps/ai-attachment) | `ai:read` |
| POST | [`/v1/me/ai/actions/{id}/confirm`](/docs/api/apps/ai-action-confirm) | `ai:write` |
| POST | [`/v1/me/ai/actions/{id}/dismiss`](/docs/api/apps/ai-action-dismiss) | `ai:write` |

`ai:write` also grants `ai:read`.

## Ids

A conversation's `id` is a uuid, such as `01a16152-7e42-70cd-91a4-b6b80ee47daa`,
because that is what the website uses as well. A message's `id` is a public id
such as `aimsg_dkr8n8ctvh9qtmce`, and an action card's one such as
`aiact_3er9kgyanz6ebeep`. Treat them all as opaque strings.

## Listening for the answer

Every conversation carries a `channel`, such as
`private-ai-chat.01a16152-7e42-70cd-91a4-b6b80ee47daa`. Subscribe to it as it
is, signed by [Listen on a websocket](/docs/api/apps/realtime) with a token
that holds `ai:read`. Only your own conversations can be heard, in the firm the
request acts in. Our own staff send the same `Propper-Workspace` header on the
signing request.

Subscribe before you ask, or straight after the answer to asking arrives. An
answer that finished before the subscription was there sent its events to
nobody, so once subscribed, [open the conversation](/docs/api/apps/ai-conversation)
once more. It says whether the answer is still being written
(`streaming_message_id`) or why it failed (`failure`).

| Event | When | Data |
| --- | --- | --- |
| `stream_start` | The answer started | Nothing to rely on. Open a bubble for the answer |
| `text_delta` | The next piece of text | `delta`, the text to append, and `message_id`, the model's own id for this piece of writing |
| `tool_call` | The assistant looked something up | `tool_name`, `arguments` |
| `stream_end` | The answer ended | `reason`: `cancelled` when it was stopped, otherwise the model's own word for finishing. Read the outcome from `turn_state` |
| `turn_state` | About once a second while writing, and at the end | `message_id` (the `aimsg_` id of the answer), `status`, `length` |
| `title_updated` | The first answer gave the conversation a title | `title` |
| `action_proposed` | The assistant proposed an action | `action`, the [card](#action-cards) to show under the answer being written |
| `action_updated` | A card was confirmed, dismissed, done or failed, from this device or another | `action`, the card as it stands now. Replace the one with the same `id` |
| `error` | The answer failed | `message`, in the person's own language, to show as it is |

`turn_state` is the one to drive the screen by. Its `status` is `streaming`
while the answer is written, then `complete`, `cancelled` or `failed`. `length`
is how many characters the answer has so far. When the text you have is shorter,
you missed a piece, so open the conversation again for the whole answer.

The `message_id` inside `text_delta` is the model's and not ours. Match an
answer by the `message_id` of `turn_state`, which is the `id` of the `answer`
that asking returned.

A failed answer sends `error` and then `turn_state` with `failed`, and no
`stream_end`. A stopped one sends `stream_end` with `cancelled`, then
`turn_state`.

These are real events from a turn that was stopped before a worker reached it:

```
stream_end  {"type":"stream_end","reason":"cancelled"}
turn_state  {"message_id":"aimsg_yrf25cr33ju3zuc2","status":"cancelled","length":0}
```

And from one that failed:

```
error       {"message":"The AI assistant could not respond. Please try again."}
turn_state  {"message_id":"aimsg_tjqupx579w3cpx9m","status":"failed","length":0}
```

## Why an answer failed

[Open the conversation](/docs/api/apps/ai-conversation) and read `failure`. Its
`reason` is one of these, with an English `message` beside it:

| `reason` | Meaning |
| --- | --- |
| `busy` | The model was overloaded. Asking again in a moment usually works |
| `unavailable` | No AI capacity was free. Not something the person can fix |
| `blocked` | The firm's allowance ran out between asking and answering |
| `empty` | The model finished without writing anything |
| `loop` | The assistant kept asking for the same thing and was stopped |
| `failed` | Anything else |

An answer that failed or was stopped before its first word is left out of the
messages. One that wrote something keeps it, with its `status`.

## Files

A question can carry up to five files of at most 10 MB each: JPEG, PNG, GIF,
WebP, PDF, plain text or CSV. They travel as base64 inside the JSON, as every
upload in this API does. The type is worked out from the bytes, never taken from
the name. The assistant reads images and documents along with the question.

## Action cards

The assistant can propose a few things when somebody asks for them. On the
team's chat: a note on a trader, a ticket assigned, tagged, resolved or closed,
an appointment in their own calendar, a payout approved, rejected or marked
paid, and a fraud flag confirmed or marked a false alarm. On the trader's chat:
a support ticket opened, and a note added to the journal of one of their own
trades. It never does them itself. Each proposal is a card under the answer,
and nothing happens until the person presses Confirm.

It proposes only what the person's role allows on the website. Asked for
anything else, it says that their role does not allow it, and who can do it
where.

A card arrives twice: live as `action_proposed` while the answer is written,
and in the answer's `actions` when the conversation is opened. Show it under
the answer with two buttons, which call
[Confirm an action](/docs/api/apps/ai-action-confirm) and
[Dismiss an action](/docs/api/apps/ai-action-dismiss).

```json
{
    "id": "aiact_zechsrzqkuuud398",
    "object": "ai_action",
    "kind": "RejectPayout",
    "title": "Reject payout #55SBUA of $150.00 for Sneha Iyer",
    "lines": [
        "Reason the trader reads: Your best day is more than 40% of the profit in this payout window, so the consistency rule is not met yet."
    ],
    "records": [
        {
            "label": "Payout #55SBUA",
            "url": "/admin/payouts/po_68u3c7naj555sbua"
        }
    ],
    "destructive": true,
    "ask": "Sneha Iyer is told in the app that it was declined, with your reason. Nothing is taken off the account.",
    "status": "proposed",
    "outcome": null,
    "expires_at": "2026-10-23T03:31:41+00:00",
    "decided_at": null
}
```

| Field | Description |
| ----- | ----------- |
| `kind` | What the card does, to pick an icon by. The team's chat: `AddTraderNote`, `AssignTicket`, `SetTicketTags`, `CloseTicket`, `CreateAppointment`, `ApprovePayout`, `RejectPayout`, `MarkPayoutPaid` or `DecideFraudFlag`. The trader's chat: `OpenSupportTicket` or `AddTradeJournalNote`. More will come, so show an unknown kind like any other |
| `title` | One line saying what pressing Confirm does, in the person's language |
| `lines` | What exactly, one line each, to show as they are |
| `records` | What it touches. `url` is a path on the firm's website, to open in a browser |
| `destructive` | `true` when the action cannot be undone or destroys something, where the website's own button is red. Draw Confirm that way |
| `ask` | What reaches somebody else or cannot be taken back, in the person's language, or `null`. When it is set, or `destructive` is `true`, show it and ask once more before confirming, as the website does. Always set on a destructive card |
| `status` | `proposed`, `confirmed` (running), `done`, `failed`, `dismissed` or `expired` |
| `outcome` | After it ran: what happened, or why it failed, in the person's language |
| `expires_at` | A card that nobody answered expires after 24 hours and can no longer be confirmed |
| `decided_at` | When it was confirmed or dismissed, `null` before |

Everything is checked again at the click: whether the person may still do
this, whether the record is still as it was when the card was made, and whether
the card is still open. A ticket a colleague assigned in the meantime fails
with an outcome that says so, rather than undoing their work, and so does a
payout another member of the team approved first. The assistant
reads what became of each card on the next question, so it never claims
something was done that was not.

## What it cannot do yet

The assistant cannot send mail, reply to a ticket, suspend a trader, move money
itself or delete anything, on the website or here. It proposes the actions
above and nothing else. On the trader's chat it cannot request a payout or buy
anything, which the trader does on those screens. It also has no idea which
screen of the app somebody is looking at, which the website tells it.