Thread replies and streaming
Reply as your bot in the thread under a message, update that reply while your bot is still writing it, and show people which step the bot is on.
These endpoints take JSON bodies and your bot token in an Authorization: Bearer header (see Authentication). IDs are 64-bit integers sent as JSON numbers, and chat IDs are too large for JavaScript numbers; see IDs are 64-bit integers.
Create a thread reply
Section titled “Create a thread reply”https://api.donutchat.com/bots/v1/thread-replies | Field | Type | Required | Description |
|---|---|---|---|
chat_id | integer | Yes | The chat. The bot must be an active member. |
parent_message_id | integer | Yes | The top-level message whose thread you reply in: message_id from a message.new event, or parent_message_id from a thread.reply.new event. A reply can’t be a parent. |
content | string | No | The reply text, up to 80,000 UTF-8 bytes. Can be empty. |
content_type | string | No | TX for plain text (default) or MD for Markdown. Not case-sensitive. You can’t change it later. |
status | string | No | OK for a finished reply (default), or GN for a reply you’ll keep updating. ok and gn also work. |
username | string | No | Sender name for this reply, up to 64 bytes. Defaults to the bot’s name. |
avatar_url | string | No | Absolute http or https URL of a picture for this reply. Defaults to the bot’s picture. |
curl -X POST https://api.donutchat.com/bots/v1/thread-replies \ -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"chat_id": 1234, "parent_message_id": 5678, "content": "The build passed. **3 tests** were skipped.", "content_type": "MD"}'// Node.js 22 or later. Set DONUT_BOT_TOKEN to your bot token.const API = 'https://api.donutchat.com/bots/v1';const TOKEN = process.env.DONUT_BOT_TOKEN;
// Keep 64-bit IDs exact: parse integers beyond Number.MAX_SAFE_INTEGER as BigInt,// and write BigInt values back as plain JSON numbers.const parseJSON = (text) => JSON.parse(text, (key, value, context) => Number.isInteger(value) && !Number.isSafeInteger(value) ? BigInt(context.source) : value);const stringifyJSON = (value) => JSON.stringify(value, (key, v) => (typeof v === 'bigint' ? JSON.rawJSON(String(v)) : v));
async function post(path, body) { const res = await fetch(API + path, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, body: stringifyJSON(body), }); const text = await res.text(); if (!res.ok) throw new Error(`POST ${path} failed: HTTP ${res.status} ${text}`); return parseJSON(text);}
// Reply in the thread of a message.new event that was parsed with parseJSON.async function replyInThread(event, markdown) { const { reply_id } = await post('/thread-replies', { chat_id: event.data.chat_id, parent_message_id: event.data.message_id, content: markdown, content_type: 'MD', }); return reply_id;}A successful request returns the new reply’s ID:
{ "ok": true, "reply_id": 9876, "chat_id": 1234, "parent_message_id": 5678 }The reply appears in the thread, and the reply count under the top-level message goes up. When a reply is created or finished as OK, people who follow the thread get a notification. GN and FL replies never notify anyone. Notifications for MD replies show the text without formatting.
There’s no idempotency key. If a create request times out and you send it again, the thread can end up with two replies.
Attach files
Section titled “Attach files”A bot can attach up to four files to a thread reply, if its owner turned on Allow file uploads for it. The setting is off for every bot until the owner turns it on.
Send the same request as multipart/form-data instead of JSON:
- One
payloadpart with the reply fields above as JSON.statusmust beOKor left out: a reply with files is created finished, and you can’t stream into it. - One to four
fileparts. Each file can be up to 10 MiB.
curl -X POST https://api.donutchat.com/bots/v1/thread-replies \ -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" \ -F 'payload={"chat_id": 1234, "parent_message_id": 5678, "content": "Screenshot of the failing step:"}' \ -F 'file=@screenshot.png' \ -F 'file=@build.log;filename=build.txt'The response is the same as for a reply without files.
- Allowed files: PNG, JPEG, GIF and WebP images, PDF documents, and UTF-8 plain text. DonutChat decides the type from the file’s contents, not from its name or the type you declare. Anything else, such as HTML, SVG, archives or programs, is refused with
415. - Names: the file name is shown to people, without any folder part. It doesn’t affect where the file is stored.
- Storage: files count toward the chat’s storage and expire after a year, like files people attach. People open them the same way.
- Limits: a bot can upload up to 30 files per hour. If a request fails, nothing from it is kept: no reply and no files.
Stream a reply
Section titled “Stream a reply”https://api.donutchat.com/bots/v1/thread-replies/stream Streaming shows the reply growing while your bot writes it:
- Create the reply with
"status": "GN".contentcan be empty. If the reply will contain Markdown or cards, also set"content_type": "MD", because streaming can’t change it later. - As text arrives, send the full text so far with
"status": "GN". - Finish with the complete text and
"status": "OK", or"status": "FL"if the bot couldn’t finish.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id | integer | Yes | Must match the reply’s chat. |
parent_message_id | integer | Yes | Must match the reply’s parent message. |
reply_id | integer | Yes | A reply that this bot created. |
content | string | Every time | The complete text so far, up to 80,000 UTF-8 bytes. It replaces the stored text, so leaving it out or sending "" empties the reply. |
status | string | Every time | GN, OK or FL (gn, ok and fl also work). If you leave it out, the server treats it as OK and finishes the reply. |
content_chunk | string | No | An optional delta, up to 80,000 UTF-8 bytes. It isn’t stored and the DonutChat apps don’t use it, so always send the full content. |
curl -X POST https://api.donutchat.com/bots/v1/thread-replies/stream \ -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"chat_id": 1234, "parent_message_id": 5678, "reply_id": 9876, "content": "Checking the build logs. The first failure is in", "status": "GN"}'The response returns the reply’s ID as message_id:
{ "ok": true, "message_id": 9876, "chat_id": 1234 }Status rules
Section titled “Status rules”| The reply is | You send | Result |
|---|---|---|
GN | GN | The text is replaced. People viewing the thread see it update. |
GN | OK | The text is replaced and the reply is finished. People who follow the thread get a notification. |
GN | FL | The text is replaced and the reply is marked as failed. No notification. |
OK or FL | OK or FL | The text and status are replaced, and the reply is marked as updated. No new notification. |
OK or FL | GN | Ignored, although the response still says "ok": true. A late GN update can’t reopen a finished reply. |
Sending the same status and text again changes nothing, so it’s safe to retry an update.
In the iPhone, Android and web apps, the status line under the reply shows Working… while it’s GN (or the step you report with activity), Taking longer than usual… after 30 seconds without an update, Finished · time after OK, Updated · time after a later change, and Response interrupted for FL. See Bot status and Response activity.
Order and pacing
Section titled “Order and pacing”- Send the updates for one reply one at a time, and wait for each response before sending the next. There are no sequence numbers: the last update the server accepts wins, so a delayed
OKorFLwith older text overwrites newer text. - Overlapping updates to the same reply can fail with
500 stream_failed. Retry once the earlier update has finished. - About 2 to 4 updates per second is enough for smooth streaming.
- Keep the full text within 80,000 UTF-8 bytes. For a longer answer, finish this reply and continue in a new one.
- A reply keeps its place in the thread however often you update it.
If DonutChat interrupts a reply
Section titled “If DonutChat interrupts a reply”During service maintenance, DonutChat can mark a reply that’s still GN as FL, and people see Response interrupted. After that, your GN updates are ignored, but a final OK or FL update still applies and replaces the text. Always finish each reply with OK or FL and the complete text.
Report progress
Section titled “Report progress”https://api.donutchat.com/bots/v1/thread-replies/activity While a reply is GN, you can report what the bot is doing. In the iPhone, Android and web apps, people see the current step under the reply and the list of steps in Response activity. You choose from a fixed set of actions; free text isn’t accepted.
| Field | Type | Required | Description |
|---|---|---|---|
chat_id, parent_message_id, reply_id | integer | Yes | Identify a reply that this bot created. |
sequence | integer | Yes | A positive number that increases with each report for this reply. A report whose sequence isn’t higher than the last accepted one is ignored. |
action | string | Yes | What the bot is doing. See the next table. |
status | string | Yes | active, completed, failed, retrying or recovered. |
task_id | string | No | A lowercase UUID that groups reports about one task. |
action | Label in the apps |
|---|---|
queued | Waiting to start… |
reading_thread | Reading thread |
search | Searching… |
reading_sources | Reading sources |
tests | Running tests |
tool | Using a tool… |
writing | Writing |
curl -X POST https://api.donutchat.com/bots/v1/thread-replies/activity \ -H "Authorization: Bearer donutbot_YOUR_TOKEN_HERE" \ -H "Content-Type: application/json" \ -d '{"chat_id": 1234, "parent_message_id": 5678, "reply_id": 9876, "sequence": 1, "action": "search", "status": "active"}'{ "ok": true, "version": 1, "accepted": true }- The body can be at most 4,096 bytes and contain only these fields. Anything else fails with
400 invalid_activity. - Within one
task_id, the action can’t change, a task can’t report again aftercompletedorrecovered, and afterfailedits next report must beretrying. retryingmust follow afailedreport, andrecoveredmust follow aretryingreport, for the same action andtask_id. This also applies when you leave outtask_id.- Reports that break these rules fail with
403 activity_unavailable. - Reports only count while the reply is
GN. Once it’s finished, or whensequenceisn’t higher than the last one, the response has"accepted": false. - Response activity keeps the last 32 reports.
Errors
Section titled “Errors”Errors return {"ok": false, "error": "<code>", "message": "<text>"}.
| HTTP | error | Endpoint | Cause |
|---|---|---|---|
| 400 | invalid_json | Create, stream | The body isn’t valid JSON, or a field has the wrong type, such as an ID sent as a string. |
| 400 | missing_chat_id | Create, stream | chat_id is missing or not positive. |
| 400 | missing_parent_message_id | Create, stream | parent_message_id is missing or not positive. |
| 400 | missing_reply_id | Stream | reply_id is missing or not positive. |
| 400 | content_too_long | Create, stream | content or content_chunk is over 80,000 UTF-8 bytes. |
| 400 | invalid_content_type | Create | content_type isn’t TX or MD. |
| 400 | invalid_avatar_url | Create | avatar_url isn’t an absolute http or https URL. |
| 400 | invalid_status | Create, stream | Create accepts GN and OK; stream accepts GN, OK and FL. |
| 400 | invalid_activity | Activity | The body is too large, isn’t valid, has unknown fields, or uses an unknown action or status. |
| 400 | invalid_multipart, missing_payload, missing_file, empty_file, too_many_files | Create with files | The multipart body isn’t a single payload part plus one to four non-empty file parts. |
| 403 | uploads_disabled | Create with files | The bot’s owner hasn’t turned on Allow file uploads. |
| 413 | file_too_large, payload_too_large, request_too_large | Create with files | A file is over 10 MiB, or the request is too large. |
| 415 | unsupported_file_type | Create with files | A file’s contents aren’t PNG, JPEG, GIF, WebP, PDF or UTF-8 text. |
| 429 | rate_limited | Create with files | The bot uploaded 30 files in the past hour. Wait, then retry. |
| 429 | uploads_busy | Create with files | Too many uploads at the moment. Retry with backoff. |
| 500 | upload_failed | Create with files | The files couldn’t be stored. Nothing was kept. Retry. |
| 503 | uploads_unavailable | Create with files | Uploads are temporarily unavailable. |
| 401 | unauthorized | All | The token is missing, malformed or invalid, or the bot is paused. |
| 403 | not_in_chat | All | The bot isn’t an active member of the chat. |
| 403 | not_owner | Stream | The reply doesn’t exist, was deleted, wasn’t created by this bot, or doesn’t match chat_id and parent_message_id. |
| 403 | activity_unavailable | Activity | The reply isn’t one this bot created, or the report breaks the task rules. |
| 500 | create_failed | Create | The parent message doesn’t exist, was deleted, or isn’t a top-level message in this chat, or username is over 64 bytes. Can also be a server error. |
| 500 | stream_failed | Stream | Another update to the same reply was being saved, or a server error occurred. Retry. |
| 500 | membership_check_failed | All | A temporary error. Retry. |
A request with the wrong HTTP method gets 405 with an empty body. During service maintenance, any endpoint can return 503 with a plain-text body; retry with backoff. All codes are listed in Errors.
Streaming pattern
Section titled “Streaming pattern”- Create one
GNreply as soon as your bot starts working, keep updating it, and finish it. The apps already show that aGNreply is in progress, so you don’t need separate “working on it” messages. For more guidance, see Progress and stopping. - Keep the complete text in memory and send all of it with every update.
- Send
statuswith every update, and always finish withOKorFL. - If the bot can’t finish, send
FLwith the text so far and a short explanation. - Buttons on interactive cards only work after the reply is
OK.
This function streams text into one reply. It uses the post helper from the Node.js example in Create a thread reply:
// Stream text from `chunks` (an async iterable of strings) into one thread reply.async function streamReply(chatId, parentId, chunks) { const ids = { chat_id: chatId, parent_message_id: parentId }; const { reply_id } = await post('/thread-replies', { ...ids, content: '', content_type: 'MD', status: 'GN', }); const update = (content, status) => post('/thread-replies/stream', { ...ids, reply_id, content, status });
let text = ''; let lastUpdate = 0; try { for await (const chunk of chunks) { text += chunk; if (Date.now() - lastUpdate >= 300) { await update(text, 'GN'); // one update at a time, about 3 per second lastUpdate = Date.now(); } } await update(text, 'OK'); } catch (error) { await update(`${text}\n\nSorry, I couldn't finish this reply.`, 'FL').catch(() => {}); throw error; }}