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).
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 | ai:read |
| GET | /v1/me/ai/conversations | ai:read |
| POST | /v1/me/ai/conversations | ai:write |
| GET | /v1/me/ai/conversations/{id} | ai:read |
| POST | /v1/me/ai/conversations/{id}/messages | ai:write |
| POST | /v1/me/ai/conversations/{id}/cancel | ai:write |
| PATCH | /v1/me/ai/conversations/{id} | ai:write |
| DELETE | /v1/me/ai/conversations/{id} | ai:write |
| GET | /v1/me/ai/messages/{id}/attachments/{index} | ai:read |
| POST | /v1/me/ai/actions/{id}/confirm | ai:write |
| POST | /v1/me/ai/actions/{id}/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 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
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 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 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 and
Dismiss an action.
{
"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.