Chat Streaming Guide
October 12, 2026
Table of contents
- How a chat works
- Reading the stream
- Events
- Resuming after a disconnect
- Limits
- Examples
- Chat or jobs?
This guide shows how to hold a two-way chat with Muse, Meta’s AI agent, through the useapi.net Muse API: send messages, read the answer live over Server-Sent Events (SSE), and pick up again after a disconnect. To see it working first, open the Live Demo.
A chat reaches the whole Muse agent: it talks, researches the web, writes and runs code, uses the apps connected to the Muse account, and makes images and videos. Each chat is a normal Muse chat, so it also shows in the muse.ai app.
How a chat works
- Start the chat with POST /chats. It answers
201with achatId. - Open GET /chats/
chatId/stream. The answer arrives as events: the agent’s text in pieces, what it is doing, the files it makes, andturn.donewhen the answer ends. - Send the next message with POST /chats/
chatId/messages. Sending does not need an open stream: the stream only lets you watch the reply live. Without one, read the reply with GET /chats/chatId, or open the stream later and it is replayed. You can also send while Muse is still answering: it takes the new message into the answer it is working on. - Stop an answer with POST /chats/
chatId/cancel.
One stream follows the whole chat, across every answer, so keep it open for as long as the conversation goes on. Past chats are listed by GET /chats and read as JSON with GET /chats/chatId.
Reading the stream
The stream needs the Authorization header. The browser’s EventSource cannot send it, so read the stream with fetch(), or with any HTTP client that can read a response as it arrives.
The response is text/event-stream. Events are separated by a blank line. Each one has a data: line with one JSON object, and some also have an id: line:
data: {"event":"initialized","chatId":"c-u12345-email:[email protected]:272b73f4-6fcb-42c2-9ec7-75cc0c020940-bot:muse","email":"[email protected]","after":0}
data: {"event":"status","status":"running"}
id: 1263
data: {"event":"message","messageId":"389bfe1b-d8a0-47ed-9b11-d9269a8ed656","text":"Write a 300-word story about the sea.","files":[]}
data: {"event":"activity","text":"is responding"}
data: {"event":"text","messageId":"assistant-msg-c736fee5-9539-4ac0-8d59-30afb00a10d1","text":"The tide came in"}
data: {"event":"text","messageId":"assistant-msg-c736fee5-9539-4ac0-8d59-30afb00a10d1","text":" like an"}
id: 1265
data: {"event":"message.done","messageId":"assistant-msg-c736fee5-9539-4ac0-8d59-30afb00a10d1"}
data: {"event":"status","status":"completed"}
data: {"event":"turn.done","status":"completed","files":[]}
Events
| Event | Meaning | When |
|---|---|---|
initialized | The stream is open. | Always first. |
message | A message you sent, with its media files as asset ids and other files as workspace paths. | When Muse receives it, also mid-answer. |
activity | What the agent is doing, for example Generating image. | During an answer. |
text | A piece of the agent’s message, or with full: true the whole message. | As the agent writes. |
message.done | The agent’s message with this messageId is complete. | At the end of each agent message. |
widget | An interactive card in the agent’s message: quick-reply choices (options), a chart or table (html, shown only in a sandboxed iframe), or another kind (data, show its fallbackText). See Answer a widget. | With the agent’s message. |
file | A file the agent made, as an assetId. | As soon as the file is ready. |
status | The chat’s state from Muse, for example running or completed. | When it changes. |
approval | The agent asks for permission before an action. The answer pauses until you reply. | When Muse asks. |
approval.resolved | A permission request ended: approved, denied or timed out. | Once per request. |
turn.done | The answer ended, completed or interrupted, with all its files. | At the end of each answer. |
error | The connection to Muse broke. The stream closes. | On a failure. |
keepalive | Nothing to report. | Every 15 seconds. |
idle | Nothing happened for 10 minutes. The stream closes. | After 10 quiet minutes. |
reconnect | The connection reached its 60-minute limit. The stream closes. | After 60 minutes. |
Event model
type StreamEvent = // TypeScript
| { event: 'initialized', chatId: string, email: string, after: number }
| { event: 'message', messageId: string, text: string, files: string[] } // your message: media as asset ids, other files as workspace paths
| { event: 'activity', text: string } // e.g. "is working", "Generating image"
| { event: 'text', messageId: string, text: string, full?: true } // full: the whole text, replaces earlier pieces
| { event: 'message.done', messageId: string }
| { event: 'widget', messageId?: string, widgetId: string, // an interactive card in the agent's message
kind: string, text: string | null, fallbackText: string | null, // show fallbackText for a kind you can't render
options?: string[], // kind option: quick replies, answer with POST /chats/{chatId}/widgets/{widgetId}
html?: string, // kind html: a card to show only in a sandboxed iframe
data?: Record<string, unknown> } // any other kind: Muse's widget data as it is
| { event: 'file', messageId?: string, assetId: string,
type: 'image' | 'video' | 'audio', mime: string, bytes: number,
origin: 'generated' | 'made' } // made: by the agent's own tools. Once per answer: a later answer may send the same file again
| { event: 'status', status: string } // e.g. running, pending_user_confirmation (this chat waits for its own approval), user_confirmed / user_denied (after your answer), completed
| { event: 'approval' // the agent asks for permission
approvalId: string
title: string | null // e.g. "Share info with deb.nodesource.com"
question: string | null // e.g. "Allow Muse to share information with deb.nodesource.com?"
details: { label: string, value: string }[] // e.g. Host, Grants, Method
options: { decision: string, label: string }[] // e.g. allow_once "Allow once", deny "Deny"
expires: string | null // ISO 8601, unanswered requests time out then
}
| { event: 'approval.resolved', approvalId: string, // once, when one of this chat's requests ends
status: string, decision: string | null } // e.g. approved / allow_once, denied / deny, timed_out
| { event: 'turn.done', status: 'completed' | 'interrupted',
files: { assetId: string, type: 'image' | 'video' | 'audio', mime: string, bytes: number }[] }
| { event: 'error', code: number, message: string } // reconnect with Last-Event-ID
| { event: 'keepalive' } // every 15 seconds
| { event: 'idle', message: string } // 10 quiet minutes, the stream closes
| { event: 'reconnect', message: string } // 60 minutes reached, the stream closes
Text
The agent’s text arrives in pieces. Join the pieces with the same messageId in order. An answer can hold several agent messages, each with its own messageId.
A text event with full: true holds the whole text of a message. Show it in place of any pieces you have for that messageId. It comes when the stream replays a finished message, for example after a reconnect. A link the agent presents on its own, such as a share link it created, also arrives this way: as an agent message whose text is the link, a text event with full: true followed by message.done.
Files
Each file the agent makes arrives as a file event with an assetId, its type, mime and bytes, once per answer. When the agent shows the same file again in a later answer, for example after rewriting it in place, the file event comes again under that answer. If you keep a list of files, de-duplicate it by assetId. Download it with GET /assets/assetId, or pass it in files of the next message or as an input of a job. turn.done lists every file of the answer again. Your own files go in with files on POST /chats and the next messages: media as asset ids from POST /assets, and documents, data or code as workspace paths from POST /files. For example, write workspace/your_files/report.pdf, then send "files": ["workspace/your_files/report.pdf"] with the message Summarize file_1.
Approvals
Before some actions, for example sending information to a website while it runs code, the agent asks for your permission:
- The stream shows a
statuseventpending_user_confirmationand anapprovalevent with the request’sapprovalId,title,question,details,optionsandexpires. The answer is paused, and noturn.donecomes while the request waits.pending_user_confirmationalways means this chat waits for one of its own requests, never another chat’s. - Send one of the
optionsdecisions to POST /chats/chatId/approvals/approvalId:allow_oncefor this one request,allow_alwaysto allow the site from now on for the whole Muse account, ordeny. - The stream shows
approval.resolvedwith the result, and astatusevent such asuser_confirmedoruser_denied. The answer goes on. Afterdenythe agent continues without the action.
A request that is not answered by expires ends as timed out. Open requests are also listed in pendingApprovals of GET /chats/chatId, and the stream sends them again after a reconnect: key them by approvalId.
Resuming after a disconnect
Keep the last id you received. An id appears only on events that close a complete part of the chat, so everything up to it has arrived.
To resume, open the stream again with that value in the Last-Event-ID header, or as ?after=. The stream replays what you missed, then goes on live: your messages, each finished agent message as one text event with full: true, and its files. A fresh stream (no Last-Event-ID) replays the newest 1000 chat events. Older messages are in GET /chats/chatId with before.
A stream opened after an answer ended replays that answer and then a status event such as completed, without a turn.done.
Reconnect with Last-Event-ID after error and after any network error. After idle (10 quiet minutes) or reconnect (60 minutes), reconnect with Last-Event-ID if you still want live updates, for example while the chat is on screen, or else when you next need it. Either way the stream resumes exactly where it left off, with no history reload.
Limits
- Each account can have about 50 streams open at the same time. More answer
429. The count is approximate: a connection you close is noticed within about a minute, so a quick burst of reconnects can briefly count more streams than are really open. Keep one stream per chat. GET /chats/streams shows the count. Close streams you no longer read. - Every stream you open shows in your usage stats (
bot=muse) as modelmuse-stream, with statusopened,refusedorfailed. Use it to see how often your app opens streams: many opens a minute usually means a reconnect loop. - Chat does not use the account’s
maxJobs, which limits jobs only. - Chat uses the account’s weekly Muse allowance, like jobs do.
Examples
Each example starts a chat, prints the answer as it streams, and reconnects with Last-Event-ID when the connection drops.
-
const token = "API token"; const api = "https://api.useapi.net/v1/muse"; const start = await fetch(`${api}/chats`, { method: "POST", headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ message: "Write a short poem about the sea, then make one image for it." }), }); const { chatId } = await start.json(); let lastId = 0; const texts = new Map(); // messageId → text so far // Reads the stream until the answer ends ("done"), or the stream closes ("again") const follow = async () => { const response = await fetch(`${api}/chats/${encodeURIComponent(chatId)}/stream`, { headers: { "Authorization": `Bearer ${token}`, "Last-Event-ID": String(lastId) }, }); if (!response.ok) throw new Error(`${response.status} ${await response.text()}`); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; for (;;) { const { done, value } = await reader.read(); if (done) return "again"; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split("\n\n"); buffer = blocks.pop(); for (const block of blocks) { const lines = block.split("\n"); const id = lines.find(l => l.startsWith("id:")); const data = lines.find(l => l.startsWith("data:")); if (id) lastId = Number(id.slice(3)); if (!data) continue; const e = JSON.parse(data.slice(5)); switch (e.event) { case "text": texts.set(e.messageId, e.full ? e.text : (texts.get(e.messageId) ?? "") + e.text); if (!e.full) process.stdout.write(e.text); break; case "message.done": console.log(); break; case "file": console.log("file:", e.type, e.assetId); break; case "turn.done": console.log("answer done:", e.status, e.files.length, "file(s)"); await reader.cancel(); return "done"; case "idle": return "done"; case "error": case "reconnect": return "again"; } } } }; for (;;) { const result = await follow().catch(err => (console.log("dropped:", err.message), "again")); if (result === "done") break; await new Promise(r => setTimeout(r, 2000)); } -
import json, time, requests from urllib.parse import quote token = "API token" api = "https://api.useapi.net/v1/muse" start = requests.post(f"{api}/chats", headers={"Authorization": f"Bearer {token}"}, json={"message": "Write a short poem about the sea, then make one image for it."}) chat_id = start.json()["chatId"] last_id = 0 def follow(): """Reads the stream until the answer ends ("done"), or the stream closes ("again").""" global last_id url = f"{api}/chats/{quote(chat_id, safe='')}/stream" headers = {"Authorization": f"Bearer {token}", "Last-Event-ID": str(last_id)} with requests.get(url, headers=headers, stream=True, timeout=(10, 60)) as response: response.raise_for_status() for line in response.iter_lines(decode_unicode=True): if line.startswith("id:"): last_id = int(line[3:]) continue if not line.startswith("data:"): continue e = json.loads(line[5:]) kind = e["event"] if kind == "text": print(e["text"], end="" if not e.get("full") else "\n", flush=True) elif kind == "message.done": print() elif kind == "file": print("file:", e["type"], e["assetId"]) elif kind == "turn.done": print("answer done:", e["status"], len(e["files"]), "file(s)") return "done" elif kind == "idle": return "done" elif kind in ("error", "reconnect"): return "again" return "again" while True: try: if follow() == "done": break except requests.RequestException as err: print("dropped:", err) time.sleep(2) -
CHATID="c-u12345-email:[email protected]:272b73f4-6fcb-42c2-9ec7-75cc0c020940-bot:muse" curl -N -H "Authorization: Bearer …" \ -H "Last-Event-ID: 1265" \ "https://api.useapi.net/v1/muse/chats/$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=''))" "$CHATID")/stream"The
-Nflag turns off buffering, so events print as they arrive.
Call POST /chats/chatId/messages from anywhere in your code, with or without a stream open. When a stream is open, the new message arrives on it as a message event, followed by the answer.
Chat or jobs?
Use POST /jobs/image and POST /jobs/video when you want one image or one video per call, with a job record, sync or async answers and replyUrl webhooks. Use chat when you want a conversation, the agent’s own words as it works, follow-up edits in the same context, or anything beyond images and videos. Both use the same asset ids, so files move freely between them.