The chat surface mirrors the app's assistant: send a message, the response **holds** until the turn completes (or your timeout), then manage the exchange. One message object shape carries everything — the exchange and its `turn`.
## The exchange object
Every chat response returns one exchange:
```responses
~~~Exchange shape
{
"data": {
"id": "msg_…",
"created_at": "2026-10-26T09:00:00Z",
"type": "text",
"text": { "body": "Summarize today in two lines" },
"attached_digest_id": null,
"is_completed": true,
"turn": {
"status": "completed",
"reply": { "text": "Here's your two-liner…", "actions": [ { "label": "Searched digests" } ] },
"error": null
}
},
"request_id": "5f0c…"
}
~~~
```
`turn.status` is the state machine — `working`, `completed`, `failed`, or `awaiting_approval`:
| `turn.status` | Carries | Meaning |
|---|---|---|
| `working` | — | The turn is still running (only seen when your timeout expires first) |
| `completed` | `turn.reply` — `{ text, actions: [{ label }] }` | The assistant's answer |
| `failed` | `turn.error` — human string | The turn failed; fix with the `retry` action |
| `awaiting_approval` | `turn.pending_action` — `{ id, label, ask }` | A tool action needs your approval — answer with `approve`/`deny` |
## List messages
```endpoint
GET /chat/messages
```
Returns the conversation's latest exchanges, newest first — the window is the latest **100** exchanges, [paged](/docs/pagination) within it (`pageInfo.totalResults` still counts all history).
### Query parameters
| Parameter | Type | Rules |
|---|---|---|
| `maxResults` | integer | 1–100, default 20 |
| `page` | integer | 1-based; `0`/negatives return an empty `200` |
### Response
```responses
~~~200 OK
{
"data": {
"items": [ { "id": "msg_…", "created_at": "…", "type": "text", "text": { "body": "…" }, "attached_digest_id": null, "is_completed": true, "turn": { "status": "completed", "reply": { "text": "…", "actions": [] }, "error": null } } ],
"pageInfo": { "totalResults": 42, "resultsPerPage": 20, "page": 1, "totalPages": 3 }
},
"request_id": "5f0c…"
}
~~~
```
`GET /chat/messages/{id}` returns one exchange by message id — the poll target while a turn runs (`404 chat.message_not_found` otherwise).
## Send a message
```endpoint
POST /chat/messages
```
### Request body
| Field | Type | Rules |
|---|---|---|
| `type` | enum | **Required.** `text` or `digest` |
| `text.body` | string | Required for `text` — 1–4000 characters after trim |
| `digest.summary_id` | string | Required for `digest` — an owned briefing (internal id or your own share id) |
| `attached_digest_id` | string | Optional, `text` only — attaches an owned briefing chip to the message |
| `timeout` | integer | Hold timeout in seconds, 1–120, default 30 |
### Request
```tabs
~~~curl
curl -X POST https://api.daily-tube.com/v1/chat/messages \
-H "Authorization: Bearer dtk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"text","text":{"body":"Summarize today in two lines"},"timeout":60}'
~~~
~~~javascript
const res = await fetch("https://api.daily-tube.com/v1/chat/messages", {
method: "POST",
headers: { Authorization: "Bearer dtk_YOUR_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ type: "text", text: { body: "Summarize today in two lines" }, timeout: 60 }),
});
const { data } = await res.json();
console.log(data.turn.status, data.turn.reply?.text);
~~~
~~~python
import urllib.request, json
body = json.dumps({
"type": "text",
"text": {"body": "Summarize today in two lines"},
"timeout": 60,
}).encode()
req = urllib.request.Request(
"https://api.daily-tube.com/v1/chat/messages",
data=body,
headers={"Authorization": "Bearer dtk_YOUR_KEY", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
turn = json.load(r)["data"]["turn"]
print(turn["status"], turn.get("reply", {}).get("text"))
~~~
```
### Response
`200` with the exchange object (above). The response arrives when the turn reaches a terminal state within your `timeout` — or with `turn.status: "working"` if it didn't finish in time; poll `GET /chat/messages/{id}` to keep waiting. A digest or attachment you don't own is a uniform `404 summary.not_found`.
## Edit and delete
- `PATCH /chat/messages/{id}` — edit the **last** exchange. Body: `{ "text": { "body": "…" }, "timeout": 30 }` — a pair-swap: the old exchange is replaced and a fresh turn runs, returning the new exchange (`200`). `400 chat.not_last` if the target isn't the latest; `400 chat.compacted` if it's part of a saved summary.
- `DELETE /chat/messages/{id}` — walk back the last exchange. `204`, no body. Stops at the compaction boundary (`400 chat.compacted`).
## Message actions
```endpoint
POST /chat/messages/{id}
```
### Request body
| Action | Body | Effect |
|---|---|---|
| `retry` | `{ "action": "retry", "timeout": 60 }` | Re-run a **failed** turn — holds like a send and returns the exchange (`200`). `409 chat.not_retryable` on a non-failed turn |
| `approve` | `{ "action": "approve", "action_id": "act_…" }` | Approve the pending tool action — the turn continues and the response holds until it settles |
| `deny` | `{ "action": "deny", "action_id": "act_…" }` | Deny it — same hold semantics |
`approve`/`deny` require the `action_id` from `turn.pending_action` and a turn in `awaiting_approval` (`409 chat.no_pending_action`).
## Errors
Every error returns the same body — `code` is the stable machine string, `message` is human: The full registry of every code lives on [Errors](/docs/errors).
```responses
~~~409 Conflict
{
"error": { "code": "chat.turn_in_progress", "message": "A turn is already in progress" },
"request_id": "5f0c…"
}
~~~
```
| Code | Status | When |
|---|---|---|
| `chat.turn_in_progress` | 409 | A live turn is running — wait or poll until it settles |
| `chat.unresolved_failed_turn` | 409 | A failed turn needs retry or delete first |
| `chat.not_retryable` / `no_pending_action` | 409 | Action preconditions |
| `chat.compacted` / `compaction_in_progress` | 400 / 409 | Compaction boundary |
| `chat.not_last` / `message_not_found` | 400 / 404 | Edit/delete guards |
| `summary.not_found` | 404 | Digest send/attach with a briefing you don't own |
| `chatbot.disabled` | 403 | Chatbot off for the account |
| `subscription.inactive` | 403 | Subscription not active |
| `rate_limit.daily_messages` | 429 | Daily chat cap reached — see [Rate limits](/docs/rate-limits) |
| `validation.invalid_input` | 400 | Bad type, empty/oversized body, bad timeout, unknown action |
> Chat sends carry their own daily message cap on top of the per-key budget — [Rate limits](/docs/rate-limits).
Chat
The chat surface mirrors the app's assistant: send a message, the response holds until the turn completes (or your timeout), then manage the exchange. One message object shape carries everything — the exchange and its turn.
The exchange object
Every chat response returns one exchange:
~~~Exchange shape
{
"data": {
"id": "msg_…",
"created_at": "2026-10-26T09:00:00Z",
"type": "text",
"text": { "body": "Summarize today in two lines" },
"attached_digest_id": null,
"is_completed": true,
"turn": {
"status": "completed",
"reply": { "text": "Here's your two-liner…", "actions": [ { "label": "Searched digests" } ] },
"error": null
}
},
"request_id": "5f0c…"
}
~~~
turn.status is the state machine — working, completed, failed, or awaiting_approval:
| turn.status | Carries | Meaning |
|---|---|---|
| working | — | The turn is still running (only seen when your timeout expires first) |
| completed | turn.reply — { text, actions: [{ label }] } | The assistant's answer |
| failed | turn.error — human string | The turn failed; fix with the retry action |
| awaiting_approval | turn.pending_action — { id, label, ask } | A tool action needs your approval — answer with approve/deny |
List messages
GET /chat/messages
Returns the conversation's latest exchanges, newest first — the window is the latest 100 exchanges, paged (/docs/pagination) within it (pageInfo.totalResults still counts all history).
Query parameters
| Parameter | Type | Rules |
|---|---|---|
| maxResults | integer | 1–100, default 20 |
| page | integer | 1-based; 0/negatives return an empty 200 |
Response
~~~200 OK
{
"data": {
"items": [ { "id": "msg_…", "created_at": "…", "type": "text", "text": { "body": "…" }, "attached_digest_id": null, "is_completed": true, "turn": { "status": "completed", "reply": { "text": "…", "actions": [] }, "error": null } } ],
"pageInfo": { "totalResults": 42, "resultsPerPage": 20, "page": 1, "totalPages": 3 }
},
"request_id": "5f0c…"
}
~~~
GET /chat/messages/{id} returns one exchange by message id — the poll target while a turn runs (404 chat.message_not_found otherwise).
Send a message
POST /chat/messages
Request body
| Field | Type | Rules |
|---|---|---|
| type | enum | Required. text or digest |
| text.body | string | Required for text — 1–4000 characters after trim |
| digest.summary_id | string | Required for digest — an owned briefing (internal id or your own share id) |
| attached_digest_id | string | Optional, text only — attaches an owned briefing chip to the message |
| timeout | integer | Hold timeout in seconds, 1–120, default 30 |
Request
~~~curl
curl -X POST https://api.daily-tube.com/v1/chat/messages \
-H "Authorization: Bearer dtk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"type":"text","text":{"body":"Summarize today in two lines"},"timeout":60}'
~~~
~~~javascript
const res = await fetch("https://api.daily-tube.com/v1/chat/messages", {
method: "POST",
headers: { Authorization: "Bearer dtk_YOUR_KEY", "Content-Type": "application/json" },
body: JSON.stringify({ type: "text", text: { body: "Summarize today in two lines" }, timeout: 60 }),
});
const { data } = await res.json();
console.log(data.turn.status, data.turn.reply?.text);
~~~
~~~python
import urllib.request, json
body = json.dumps({
"type": "text",
"text": {"body": "Summarize today in two lines"},
"timeout": 60,
}).encode()
req = urllib.request.Request(
"https://api.daily-tube.com/v1/chat/messages",
data=body,
headers={"Authorization": "Bearer dtk_YOUR_KEY", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
turn = json.load(r)["data"]["turn"]
print(turn["status"], turn.get("reply", {}).get("text"))
~~~
Response
200 with the exchange object (above). The response arrives when the turn reaches a terminal state within your timeout — or with turn.status: "working" if it didn't finish in time; poll GET /chat/messages/{id} to keep waiting. A digest or attachment you don't own is a uniform 404 summary.not_found.
Edit and delete
• PATCH /chat/messages/{id} — edit the last exchange. Body: { "text": { "body": "…" }, "timeout": 30 } — a pair-swap: the old exchange is replaced and a fresh turn runs, returning the new exchange (200). 400 chat.not_last if the target isn't the latest; 400 chat.compacted if it's part of a saved summary.
• DELETE /chat/messages/{id} — walk back the last exchange. 204, no body. Stops at the compaction boundary (400 chat.compacted).
Message actions
POST /chat/messages/{id}
Request body
| Action | Body | Effect |
|---|---|---|
| retry | { "action": "retry", "timeout": 60 } | Re-run a failed turn — holds like a send and returns the exchange (200). 409 chat.not_retryable on a non-failed turn |
| approve | { "action": "approve", "action_id": "act_…" } | Approve the pending tool action — the turn continues and the response holds until it settles |
| deny | { "action": "deny", "action_id": "act_…" } | Deny it — same hold semantics |
approve/deny require the action_id from turn.pending_action and a turn in awaiting_approval (409 chat.no_pending_action).
Errors
Every error returns the same body — code is the stable machine string, message is human: The full registry of every code lives on Errors (/docs/errors).
~~~409 Conflict
{
"error": { "code": "chat.turn_in_progress", "message": "A turn is already in progress" },
"request_id": "5f0c…"
}
~~~
| Code | Status | When |
|---|---|---|
| chat.turn_in_progress | 409 | A live turn is running — wait or poll until it settles |
| chat.unresolved_failed_turn | 409 | A failed turn needs retry or delete first |
| chat.not_retryable / no_pending_action | 409 | Action preconditions |
| chat.compacted / compaction_in_progress | 400 / 409 | Compaction boundary |
| chat.not_last / message_not_found | 400 / 404 | Edit/delete guards |
| summary.not_found | 404 | Digest send/attach with a briefing you don't own |
| chatbot.disabled | 403 | Chatbot off for the account |
| subscription.inactive | 403 | Subscription not active |
| rate_limit.daily_messages | 429 | Daily chat cap reached — see Rate limits (/docs/rate-limits) |
| validation.invalid_input | 400 | Bad type, empty/oversized body, bad timeout, unknown action |
Chat sends carry their own daily message cap on top of the per-key budget — Rate limits (/docs/rate-limits).
The chat surface mirrors the app's assistant: send a message, the response holds until the turn completes (or your timeout), then manage the exchange. One message object shape carries everything — the exchange and its turn.
turn.status is the state machine — working, completed, failed, or awaiting_approval:
turn.status
Carries
Meaning
working
—
The turn is still running (only seen when your timeout expires first)
completed
turn.reply — { text, actions: [{ label }] }
The assistant's answer
failed
turn.error — human string
The turn failed; fix with the retry action
awaiting_approval
turn.pending_action — { id, label, ask }
A tool action needs your approval — answer with approve/deny
List messages
GET/chat/messagesapi.daily-tube.com/v1
Returns the conversation's latest exchanges, newest first — the window is the latest 100 exchanges, paged within it (pageInfo.totalResults still counts all history).
import urllib.request, json
body = json.dumps({
"type": "text",
"text": {"body": "Summarize today in two lines"},
"timeout": 60,
}).encode()
req = urllib.request.Request(
"https://api.daily-tube.com/v1/chat/messages",
data=body,
headers={"Authorization": "Bearer dtk_YOUR_KEY", "Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
turn = json.load(r)["data"]["turn"]
print(turn["status"], turn.get("reply", {}).get("text"))
import urllib.request, json body = json.dumps({ "type": "text", "text": {"body": "Summarize today in two lines"}, "timeout": 60, }).encode() req = urllib.request.Request( "https://api.daily-tube.com/v1/chat/messages", data=body, headers={"Authorization": "Bearer dtk_YOUR_KEY", "Content-Type": "application/json"}, ) with urllib.request.urlopen(req) as r: turn = json.load(r)["data"]["turn"] print(turn["status"], turn.get("reply", {}).get("text"))
Response
200 with the exchange object (above). The response arrives when the turn reaches a terminal state within your timeout — or with turn.status: "working" if it didn't finish in time; poll GET /chat/messages/{id} to keep waiting. A digest or attachment you don't own is a uniform 404 summary.not_found.
Edit and delete
PATCH /chat/messages/{id} — edit the last exchange. Body: { "text": { "body": "…" }, "timeout": 30 } — a pair-swap: the old exchange is replaced and a fresh turn runs, returning the new exchange (200). 400 chat.not_last if the target isn't the latest; 400 chat.compacted if it's part of a saved summary.
DELETE /chat/messages/{id} — walk back the last exchange. 204, no body. Stops at the compaction boundary (400 chat.compacted).
Message actions
POST/chat/messages/{id}api.daily-tube.com/v1
Request body
Action
Body
Effect
retry
{ "action": "retry", "timeout": 60 }
Re-run a failed turn — holds like a send and returns the exchange (200). 409 chat.not_retryable on a non-failed turn
approve
{ "action": "approve", "action_id": "act_…" }
Approve the pending tool action — the turn continues and the response holds until it settles
deny
{ "action": "deny", "action_id": "act_…" }
Deny it — same hold semantics
approve/deny require the action_id from turn.pending_action and a turn in awaiting_approval (409 chat.no_pending_action).
Errors
Every error returns the same body — code is the stable machine string, message is human: The full registry of every code lives on Errors.
{
"error": { "code": "chat.turn_in_progress", "message": "A turn is already in progress" },
"request_id": "5f0c…"
}
{ "error": { "code": "chat.turn_in_progress", "message": "A turn is already in progress" }, "request_id": "5f0c…" }
Code
Status
When
chat.turn_in_progress
409
A live turn is running — wait or poll until it settles