Skip to content
donutchat.com

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.

POST https://api.donutchat.com/bots/v1/thread-replies
FieldTypeRequiredDescription
chat_idintegerYesThe chat. The bot must be an active member.
parent_message_idintegerYesThe 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.
contentstringNoThe reply text, up to 80,000 UTF-8 bytes. Can be empty.
content_typestringNoTX for plain text (default) or MD for Markdown. Not case-sensitive. You can’t change it later.
statusstringNoOK for a finished reply (default), or GN for a reply you’ll keep updating. ok and gn also work.
usernamestringNoSender name for this reply, up to 64 bytes. Defaults to the bot’s name.
avatar_urlstringNoAbsolute http or https URL of a picture for this reply. Defaults to the bot’s picture.
Terminal window
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"}'

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.

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 payload part with the reply fields above as JSON. status must be OK or left out: a reply with files is created finished, and you can’t stream into it.
  • One to four file parts. Each file can be up to 10 MiB.
Terminal window
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.
POST https://api.donutchat.com/bots/v1/thread-replies/stream

Streaming shows the reply growing while your bot writes it:

  1. Create the reply with "status": "GN". content can be empty. If the reply will contain Markdown or cards, also set "content_type": "MD", because streaming can’t change it later.
  2. As text arrives, send the full text so far with "status": "GN".
  3. Finish with the complete text and "status": "OK", or "status": "FL" if the bot couldn’t finish.
FieldTypeRequiredDescription
chat_idintegerYesMust match the reply’s chat.
parent_message_idintegerYesMust match the reply’s parent message.
reply_idintegerYesA reply that this bot created.
contentstringEvery timeThe 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.
statusstringEvery timeGN, 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_chunkstringNoAn 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.
Terminal window
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 }
The reply isYou sendResult
GNGNThe text is replaced. People viewing the thread see it update.
GNOKThe text is replaced and the reply is finished. People who follow the thread get a notification.
GNFLThe text is replaced and the reply is marked as failed. No notification.
OK or FLOK or FLThe text and status are replaced, and the reply is marked as updated. No new notification.
OK or FLGNIgnored, 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.

  • 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 OK or FL with 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.

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.

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

FieldTypeRequiredDescription
chat_id, parent_message_id, reply_idintegerYesIdentify a reply that this bot created.
sequenceintegerYesA positive number that increases with each report for this reply. A report whose sequence isn’t higher than the last accepted one is ignored.
actionstringYesWhat the bot is doing. See the next table.
statusstringYesactive, completed, failed, retrying or recovered.
task_idstringNoA lowercase UUID that groups reports about one task.
actionLabel in the apps
queuedWaiting to start…
reading_threadReading thread
searchSearching…
reading_sourcesReading sources
testsRunning tests
toolUsing a tool…
writingWriting
Terminal window
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 after completed or recovered, and after failed its next report must be retrying.
  • retrying must follow a failed report, and recovered must follow a retrying report, for the same action and task_id. This also applies when you leave out task_id.
  • Reports that break these rules fail with 403 activity_unavailable.
  • Reports only count while the reply is GN. Once it’s finished, or when sequence isn’t higher than the last one, the response has "accepted": false.
  • Response activity keeps the last 32 reports.

Errors return {"ok": false, "error": "<code>", "message": "<text>"}.

HTTPerrorEndpointCause
400invalid_jsonCreate, streamThe body isn’t valid JSON, or a field has the wrong type, such as an ID sent as a string.
400missing_chat_idCreate, streamchat_id is missing or not positive.
400missing_parent_message_idCreate, streamparent_message_id is missing or not positive.
400missing_reply_idStreamreply_id is missing or not positive.
400content_too_longCreate, streamcontent or content_chunk is over 80,000 UTF-8 bytes.
400invalid_content_typeCreatecontent_type isn’t TX or MD.
400invalid_avatar_urlCreateavatar_url isn’t an absolute http or https URL.
400invalid_statusCreate, streamCreate accepts GN and OK; stream accepts GN, OK and FL.
400invalid_activityActivityThe body is too large, isn’t valid, has unknown fields, or uses an unknown action or status.
400invalid_multipart, missing_payload, missing_file, empty_file, too_many_filesCreate with filesThe multipart body isn’t a single payload part plus one to four non-empty file parts.
403uploads_disabledCreate with filesThe bot’s owner hasn’t turned on Allow file uploads.
413file_too_large, payload_too_large, request_too_largeCreate with filesA file is over 10 MiB, or the request is too large.
415unsupported_file_typeCreate with filesA file’s contents aren’t PNG, JPEG, GIF, WebP, PDF or UTF-8 text.
429rate_limitedCreate with filesThe bot uploaded 30 files in the past hour. Wait, then retry.
429uploads_busyCreate with filesToo many uploads at the moment. Retry with backoff.
500upload_failedCreate with filesThe files couldn’t be stored. Nothing was kept. Retry.
503uploads_unavailableCreate with filesUploads are temporarily unavailable.
401unauthorizedAllThe token is missing, malformed or invalid, or the bot is paused.
403not_in_chatAllThe bot isn’t an active member of the chat.
403not_ownerStreamThe reply doesn’t exist, was deleted, wasn’t created by this bot, or doesn’t match chat_id and parent_message_id.
403activity_unavailableActivityThe reply isn’t one this bot created, or the report breaks the task rules.
500create_failedCreateThe 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.
500stream_failedStreamAnother update to the same reply was being saved, or a server error occurred. Retry.
500membership_check_failedAllA 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.

  • Create one GN reply as soon as your bot starts working, keep updating it, and finish it. The apps already show that a GN reply 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 status with every update, and always finish with OK or FL.
  • If the bot can’t finish, send FL with 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;
}
}