The assistant

.md

How an app talks to the firm's AI assistant, and how its answers arrive over a websocket.

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.

WhoChat (surface)What it knows
A tradertrader_chatTheir own accounts, payouts, tickets and the firm's public knowledge
Somebody who helps run the firm: the owner, or staff with a roleteam_chatThe admin screens their role may open
Our own staff, with Propper-Workspace naming a firmteam_chatThat 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

MethodPathScope
GET/v1/me/aiai:read
GET/v1/me/ai/conversationsai:read
POST/v1/me/ai/conversationsai:write
GET/v1/me/ai/conversations/{id}ai:read
POST/v1/me/ai/conversations/{id}/messagesai:write
POST/v1/me/ai/conversations/{id}/cancelai: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}/confirmai:write
POST/v1/me/ai/actions/{id}/dismissai: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).

EventWhenData
stream_startThe answer startedNothing to rely on. Open a bubble for the answer
text_deltaThe next piece of textdelta, the text to append, and message_id, the model's own id for this piece of writing
tool_callThe assistant looked something uptool_name, arguments
stream_endThe answer endedreason: cancelled when it was stopped, otherwise the model's own word for finishing. Read the outcome from turn_state
turn_stateAbout once a second while writing, and at the endmessage_id (the aimsg_ id of the answer), status, length
title_updatedThe first answer gave the conversation a titletitle
action_proposedThe assistant proposed an actionaction, the card to show under the answer being written
action_updatedA card was confirmed, dismissed, done or failed, from this device or anotheraction, the card as it stands now. Replace the one with the same id
errorThe answer failedmessage, 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:

reasonMeaning
busyThe model was overloaded. Asking again in a moment usually works
unavailableNo AI capacity was free. Not something the person can fix
blockedThe firm's allowance ran out between asking and answering
emptyThe model finished without writing anything
loopThe assistant kept asking for the same thing and was stopped
failedAnything 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
}
FieldDescription
kindWhat 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
titleOne line saying what pressing Confirm does, in the person's language
linesWhat exactly, one line each, to show as they are
recordsWhat it touches. url is a path on the firm's website, to open in a browser
destructivetrue when the action cannot be undone or destroys something, where the website's own button is red. Draw Confirm that way
askWhat 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
statusproposed, confirmed (running), done, failed, dismissed or expired
outcomeAfter it ran: what happened, or why it failed, in the person's language
expires_atA card that nobody answered expires after 24 hours and can no longer be confirmed
decided_atWhen 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.