# Threa developer docs (full) > Concatenation of every page under https://threa.io/developers, generated at build. > The canonical API contract is the OpenAPI spec: https://threa.io/openapi.json > Placeholders: YOUR_WORKSPACE_ID is the ws_… id in the app URL after /w/; YOUR_API_KEY is a key from Settings > API keys. --- *Source: https://threa.io/developers* # Threa's public API. A REST API over your workspace: messages, streams, members, attachments, and the memos Threa builds from your conversations. The memory Ariadne reads is the memory your own agent reads, through scoped keys you issue yourself. Add your workspace ID and an API key in the Credentials panel (top right). Every sample on these pages then fills in your real values, and the runnable ones call the API and print the response. What you paste stays in your browser and is sent only on requests you run yourself. > **CLI and MCP server** — Alongside raw REST there is a `threa` command-line tool and an MCP server (`threa mcp serve`) for agent hosts, both installed from the open-source repo. See [CLI & MCP server](https://threa.io/developers/cli.md). ## Base URL The API lives at `https://app.threa.io` under the stable `/api/v1` prefix, scoped to a workspace: `/api/v1/workspaces/{workspaceId}/…`. Your workspace ID is in the app URL, after `/w/`. ## Quickstart 1. **Create an API key** In the app, open **Settings → API keys**, create a user key, pick the scopes you need, and copy it once. Full walkthrough in [Authentication](https://threa.io/developers/authentication.md). 2. **Add it to the Credentials panel** Open the panel (top right) and enter your workspace ID and key. The status turns green once a call to `/me` succeeds. 3. **Confirm who you are** `GET /me` needs no scope. It reports the identity behind the key. Run it: *who am I:* ```bash curl https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/me \ -H "Authorization: Bearer YOUR_API_KEY" ``` 4. **Search your own workspace** With `memos:read`, search what Threa has remembered. It's the same retrieval Ariadne uses, with source links and timestamps. *search memos:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/memos/search \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "auth", "limit": 5 }' ``` ## Working with an AI agent These docs ship in machine-readable form. Every page here has a markdown mirror at the same URL plus `.md`, [/llms.txt](https://threa.io/llms.txt) indexes them, and [/llms-full.txt](https://threa.io/llms-full.txt) is the whole set in one fetch. The [OpenAPI spec](https://threa.io/openapi.json) is the canonical contract. To point an agent at the API, paste: *prompt for your agent:* ```text Integrate with the Threa API. Read https://threa.io/llms-full.txt for the docs and https://threa.io/openapi.json for the exact contract. The base URL is https://app.threa.io, my workspace ID is YOUR_WORKSPACE_ID, and my API key is in the THREA_API_KEY environment variable (send it as "Authorization: Bearer ..."). Verify the key with GET /api/v1/workspaces/YOUR_WORKSPACE_ID/me before anything else. ``` Don't paste the key itself into a prompt. Put it in an environment variable and let the agent read it from there. ## What you can build - **Read and write messages** in any stream you can access, with idempotent sends. - **Search** messages and memos semantically or exactly, scoped to streams, types, and dates. - **Pull memos** with their source messages, so an external agent works from the same knowledge as Ariadne. - **Run your own agent** as a bot: heartbeat a runtime, claim invocations when it's mentioned, post replies. The Pi remote extension works this way. - **Upload and read attachments**, including the text Threa extracts from them. Next: [Authentication](https://threa.io/developers/authentication.md) for keys and scopes, then [Recipes](https://threa.io/developers/recipes.md) for worked examples. --- *Source: https://threa.io/developers/authentication* # Keys and scopes. Every request carries a bearer token. The prefix decides whether you act as a person or as a bot, and the scopes on the key decide what you're allowed to touch. You create keys in the app and copy them once. ## The header Send the key as a bearer token on every request: *authorization:* ```bash curl https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/me \ -H "Authorization: Bearer YOUR_API_KEY" ``` `GET /me` takes no scope and returns the identity behind the key, either a `user` or a `bot` principal. It's the quickest way to confirm a key is live and pointed at the right workspace. ## Three kinds of key Two are bots with their own identity in the workspace; you grant them access and add them where they should take part. Both use the same `threa_bk_` prefix, and what differs is whether the bot is yours or the workspace's. The third carries your own identity and access. ### Personal bot key threa\_bk\_ A bot you own, with its own identity in the workspace: your local Pi, an OpenClaw, an agent on your laptop. You add it to the streams it should see, and it acts as itself, not as you. **Use it when:** you're running your own agent and want it to show up as its own participant. ### Workspace bot key threa\_bk\_ A shared bot, created by an admin, that belongs to the workspace rather than a person: a GitHub notifier, a CI poster, an integration. It has its own identity and is added to the streams it posts in. **Use it when:** a tool or service should read and post as a named workspace bot, independent of any one member. ### Personal access key threa\_uk\_ Carries your identity and your access, so it can never do more than you can; if your role narrows later, so does the key. Messages it sends are attributed to you, flagged as sent via the API. **Use it when:** you're automating your own actions: exports, scripts, or a personal agent reading your workspace as you. ## Creating a key A **personal access key** is created under **Settings → API keys**: name it, pick scopes, and copy the `threa_uk_…` value once (up to 25 per person). A **bot key** is created from a bot's own settings. Make the bot first (a personal bot in your settings, or a shared bot if you're an admin), then mint a `threa_bk_…` key on it (up to 25 per bot), give it the scopes it needs, and add the bot to the streams it should see. Keep scopes as narrow as the job allows. You can edit them later, and a narrow key fails safe. The table below lists what each one unlocks. ## Scopes These are the scopes a key can carry. Workspace-admin powers (managing members, creating shared bots, owning the workspace) are deliberately not grantable to API keys. | Scope | Grants | | --- | --- | | messages:read | Read messages in streams you can access | | messages:write | Send, edit, and delete messages your key created | | messages:search | Search messages across the workspace | | streams:read | List and read streams and their members | | users:read | List and search workspace users | | memos:read | Search memos and read their source messages | | attachments:read | Search attachments and read extracted text | | attachments:write | Upload attachments | | bot-runtime:read | Read bot runtime presence and sessions | | bot-runtime:write | Heartbeat a runtime and link sessions | | bot-invocations:read | Read pending and claimed invocations | | bot-invocations:write | Claim, step, complete, and fail invocations | > **How keys are stored** — Only a hash of the key is kept server-side, looked up by a short prefix and compared in constant time. Threa cannot show you a key again after creation, and a revoked key stops working immediately. ## What a missing scope looks like A valid key missing the required scope returns `404`, the same response as a resource that isn't there, so the API never confirms something exists to a caller that can't access it. A missing or invalid key returns `401`. See [Operations](https://threa.io/developers/operations.md) for the full error shape. --- *Source: https://threa.io/developers/reference* # API reference. Every endpoint, generated from the [OpenAPI spec](https://threa.io/openapi.json) we ship. Set your workspace ID and key in the Credentials panel (top right) and the read-only examples run against your own workspace as you read. The base is `https://app.threa.io`, under the stable `/api/v1` prefix and scoped to a workspace: `/api/v1/workspaces/{workspaceId}/…`. The `v1` segment does not move as the API evolves; versions are dated and selected with the `Threa-Version` header, described in [Versioning](https://threa.io/developers/versioning.md). Every request authenticates with `Authorization: Bearer `. Error shapes, paging, idempotency, and rate limits live in [Operations](https://threa.io/developers/operations.md). Version [2026-07-12 latest](https://threa.io/developers/reference.md) ## Identity Confirm who a key belongs to and list the bots you own. ### GET /api/v1/workspaces/{workspaceId}/me **Get the authenticated principal** Returns a discriminated union describing the authenticated principal: the API-key owner (\`kind: "user"\`) or the bot whose key is in use (\`kind: "bot"\`). Also reports the key's API version pin, the version this request resolved to, and the supported versions. Used by clients (e.g. the OpenClaw channel plugin) to verify their key and discover their identity after pairing. Required scope: none — any valid key #### Response 200 - `data` (object, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /me:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/me" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "kind": "user", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "userId": "usr_01jd2q4z8kxw9v7r3m5t8n", "apiVersion": { "pinned": "…", "resolved": "…", "current": "…", "supported": [ "…" ] } } } ``` ### GET /api/v1/workspaces/{workspaceId}/me/bots **List my personal bots** For user-scoped keys: lists the authenticated user's personal bots, optionally filtered by trait. Used by the frontend to enumerate quick-switcher commands. Bot-scoped keys receive 403. Required scope: none — any valid key #### Query parameters - `traits` (string) — one of: mentionable, active-scratchpad #### Response 200 - `data` (object[], required) - `id` (string, required) - `workspaceId` (string, required) - `traits` (string[], required) - `slug` (string | null, required) - `name` (string, required) - `description` (string | null, required) - `avatarEmoji` (string | null, required) - `avatarUrl` (string | null, required) - `archivedAt` (string | null, required) - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `type` (string, required) - `ownerUserId` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /me/bots:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/me/bots" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "id": "usr_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "traits": [ "mentionable" ], "slug": "api-v3", "name": "Maya Reyes", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "avatarEmoji": "…", "avatarUrl": "https://files.threa.io/attachments/rotation-flow.png", "archivedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z", "type": "…", "ownerUserId": "usr_01jd2q4z8kxw9v7r3m5t8n" } ] } ``` ## Streams List and inspect streams (channels, scratchpads, threads) Recipes [Notify a stream from CI →](https://threa.io/developers/recipes.md#ci-notify) ### GET /api/v1/workspaces/{workspaceId}/streams **List streams** List streams accessible to this API key, with optional type and text filters. Required scope: `streams:read` #### Query parameters - `type` (any, required) - `query` (string) - `after` (string) - `limit` (integer) — 1–200 · default 50 - `includeArchived` (string) — one of: true, false #### Response 200 - `data` (object[], required) - `id` (string, required) - `type` (string, required) — one of: scratchpad, channel, dm, thread, system - `displayName` (string, required) - `slug` (string) - `description` (string) - `visibility` (string, required) - `memoryMode` (string, required) — one of: auto, off. GAM memory automation gate: 'auto' extracts memos, 'off' disables it - `parentStreamId` (string) - `rootStreamId` (string) - `parentMessageId` (string) - `createdAt` (string, required) — date-time - `archivedAt` (string) — date-time - `hasMore` (boolean, required) - `cursor` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /streams:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams?type=channel&limit=20" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "id": "stream_01jd2q4z8kxw9v7r3m5t8n", "type": "scratchpad", "displayName": "api-v3", "slug": "api-v3", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "visibility": "open", "memoryMode": "auto", "parentStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "parentMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" } ], "hasMore": false, "cursor": null } ``` ### GET /api/v1/workspaces/{workspaceId}/streams/{streamId} **Get a stream** Required scope: `streams:read` #### Path parameters - `streamId` (string, required) — Stream ID (prefixed ULID) #### Response 200 - `data` (object, required) - `id` (string, required) - `type` (string, required) — one of: scratchpad, channel, dm, thread, system - `displayName` (string, required) - `slug` (string) - `description` (string) - `visibility` (string, required) - `memoryMode` (string, required) — one of: auto, off. GAM memory automation gate: 'auto' extracts memos, 'off' disables it - `parentStreamId` (string) - `rootStreamId` (string) - `parentMessageId` (string) - `createdAt` (string, required) — date-time - `archivedAt` (string) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /streams/{streamId}:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "id": "stream_01jd2q4z8kxw9v7r3m5t8n", "type": "scratchpad", "displayName": "api-v3", "slug": "api-v3", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "visibility": "open", "memoryMode": "auto", "parentStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "parentMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" } } ``` ### PATCH /api/v1/workspaces/{workspaceId}/streams/{streamId} **Set a stream's description** Set the stream's description from markdown (parsed to rich text, same as message content). Send an empty string to clear it. User-scoped keys attribute the change to the key owner; workspace-scoped keys to the bot. Required scope: `streams:write` #### Path parameters - `streamId` (string, required) — Stream ID (prefixed ULID) #### Request body - `description` (string, required) — max length 10000 #### Response 200 - `data` (object, required) - `id` (string, required) - `type` (string, required) — one of: scratchpad, channel, dm, thread, system - `displayName` (string, required) - `slug` (string) - `description` (string) - `visibility` (string, required) - `memoryMode` (string, required) — one of: auto, off. GAM memory automation gate: 'auto' extracts memos, 'off' disables it - `parentStreamId` (string) - `rootStreamId` (string) - `parentMessageId` (string) - `createdAt` (string, required) — date-time - `archivedAt` (string) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *PATCH /streams/{streamId}:* ```bash curl -X PATCH https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "description": "string" }' ``` *Response 200 · example:* ```json { "data": { "id": "stream_01jd2q4z8kxw9v7r3m5t8n", "type": "scratchpad", "displayName": "api-v3", "slug": "api-v3", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "visibility": "open", "memoryMode": "auto", "parentStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "parentMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" } } ``` ### GET /api/v1/workspaces/{workspaceId}/streams/{streamId}/members **List stream members** Required scope: `streams:read` #### Path parameters - `streamId` (string, required) — Stream ID (prefixed ULID) #### Query parameters - `after` (string) - `limit` (integer) — 1–200 · default 50 #### Response 200 - `data` (object[], required) - `userId` (string, required) - `name` (string, required) - `slug` (string, required) - `avatarUrl` (string) - `joinedAt` (string, required) — date-time - `hasMore` (boolean, required) - `cursor` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /streams/{streamId}/members:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/members" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "userId": "usr_01jd2q4z8kxw9v7r3m5t8n", "name": "api-v3", "slug": "api-v3", "avatarUrl": "https://files.threa.io/attachments/rotation-flow.png", "joinedAt": "2026-03-12T11:42:00.000Z" } ], "hasMore": false, "cursor": null } ``` ## Messages Read, send, update, and delete messages Recipes [Notify a stream from CI →](https://threa.io/developers/recipes.md#ci-notify)[Mirror a search into your own tool →](https://threa.io/developers/recipes.md#mirror-search) ### POST /api/v1/workspaces/{workspaceId}/messages/search **Search messages** Full-text and optional semantic search across accessible streams. Required scope: `messages:search` #### Request body - `query` (string, required) — min length 1 - `semantic` (boolean, required) — default false - `exact` (boolean, required) — default false - `streams` (string[]) - `from` (string) - `type` (string[]) - `before` (string) — date-time - `after` (string) — date-time - `limit` (integer, required) — 1–50 · default 20 #### Response 200 - `data` (object[], required) - `id` (string, required) - `streamId` (string, required) - `sequence` (string, required) — Numeric sequence as string - `content` (string, required) - `authorId` (string, required) - `authorType` (string, required) — one of: user, persona, system, bot - `authorDisplayName` (string) - `replyCount` (integer, required) — -9007199254740991–9007199254740991 - `metadata` (object, required) — External references attached by the sender. Always present; empty when unset. - `editedAt` (string) — date-time - `createdAt` (string, required) — date-time - `rank` (number, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /messages/search:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/messages/search \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "auth", "semantic": true, "exact": false, "limit": 5 }' ``` *Response 200 · example:* ```json { "data": [ { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sequence": "412", "content": "Picking the auth refactor back up next sprint.", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "replyCount": 2, "metadata": {}, "editedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z", "rank": 1 } ] } ``` ### GET /api/v1/workspaces/{workspaceId}/streams/{streamId}/messages **List messages in a stream** Cursor-paginated message list. Use \`before\` or \`after\` sequence numbers. Required scope: `messages:read` #### Path parameters - `streamId` (string, required) — Stream ID (prefixed ULID) #### Query parameters - `before` (string) - `after` (string) - `limit` (integer) — 1–100 · default 50 #### Response 200 - `data` (object[], required) - `id` (string, required) - `streamId` (string, required) - `sequence` (string, required) — Numeric sequence as string - `authorId` (string, required) - `authorType` (string, required) — one of: user, persona, system, bot - `authorDisplayName` (string) - `content` (string, required) - `replyCount` (integer, required) — -9007199254740991–9007199254740991 - `threadStreamId` (string) - `clientMessageId` (string) - `sentVia` (string) — Present when message was sent via API on behalf of a user - `metadata` (object, required) — External references attached by the sender. Always present; empty when unset. - `attachments` (object[]) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `sizeBytes` (integer, required) — -9007199254740991–9007199254740991 - `processingStatus` (string) — one of: pending, processing, completed, failed, skipped - `width` (integer) — -9007199254740991–9007199254740991 - `height` (integer) — -9007199254740991–9007199254740991 - `editedAt` (string) — date-time - `createdAt` (string, required) — date-time - `hasMore` (boolean, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /streams/{streamId}/messages:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/messages" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sequence": "412", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "content": "Picking the auth refactor back up next sprint.", "replyCount": 2, "threadStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "clientMessageId": "ci-deploy-2.4.1", "sentVia": "…", "metadata": {}, "attachments": [ { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "width": 1, "height": 1 } ], "editedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z" } ], "hasMore": false } ``` ### POST /api/v1/workspaces/{workspaceId}/streams/{streamId}/messages **Send a message** Send a message. Workspace-scoped keys send as a bot; user-scoped keys send on behalf of the key owner. Optionally declare the message's conversation via \`conversation\`: \`{intent: "new"}\` starts a fresh conversation, \`{intent: "existing", conversationId}\` posts into one under the same root stream. Required scope: `messages:write` #### Path parameters - `streamId` (string, required) — Stream ID (prefixed ULID) #### Request body - `content` (string, required) — min length 1 - `clientMessageId` (string) — max length 128 - `metadata` (object) - `conversation` (object) #### Response 201 - `data` (object, required) - `id` (string, required) - `streamId` (string, required) - `sequence` (string, required) — Numeric sequence as string - `authorId` (string, required) - `authorType` (string, required) — one of: user, persona, system, bot - `authorDisplayName` (string) - `content` (string, required) - `replyCount` (integer, required) — -9007199254740991–9007199254740991 - `threadStreamId` (string) - `clientMessageId` (string) - `sentVia` (string) — Present when message was sent via API on behalf of a user - `metadata` (object, required) — External references attached by the sender. Always present; empty when unset. - `attachments` (object[]) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `sizeBytes` (integer, required) — -9007199254740991–9007199254740991 - `processingStatus` (string) — one of: pending, processing, completed, failed, skipped - `width` (integer) — -9007199254740991–9007199254740991 - `height` (integer) — -9007199254740991–9007199254740991 - `editedAt` (string) — date-time - `createdAt` (string, required) — date-time - `conversationId` (string) — The conversation the message was assigned to; present when the request declared a `conversation`. #### Status codes - `201` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /streams/{streamId}/messages:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "string" }' ``` *Response 201 · example:* ```json { "data": { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sequence": "412", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "content": "Picking the auth refactor back up next sprint.", "replyCount": 2, "threadStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "clientMessageId": "ci-deploy-2.4.1", "sentVia": "…", "metadata": {}, "attachments": [ { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "width": 1, "height": 1 } ], "editedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z" }, "conversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n" } ``` ### POST /api/v1/workspaces/{workspaceId}/messages/find-by-metadata **Find messages by metadata** Find non-deleted messages whose \`metadata\` contains all the given key/value pairs (AND-containment). Useful for dedup flows, e.g. 'has a message already been posted for this GitHub PR event?'. Required scope: `messages:read` #### Request body - `metadata` (object, required) - `streamId` (string) — min length 1 - `limit` (integer, required) — 1–100 · default 20 #### Response 200 - `data` (object[], required) - `id` (string, required) - `streamId` (string, required) - `sequence` (string, required) — Numeric sequence as string - `authorId` (string, required) - `authorType` (string, required) — one of: user, persona, system, bot - `authorDisplayName` (string) - `content` (string, required) - `replyCount` (integer, required) — -9007199254740991–9007199254740991 - `threadStreamId` (string) - `clientMessageId` (string) - `sentVia` (string) — Present when message was sent via API on behalf of a user - `metadata` (object, required) — External references attached by the sender. Always present; empty when unset. - `attachments` (object[]) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `sizeBytes` (integer, required) — -9007199254740991–9007199254740991 - `processingStatus` (string) — one of: pending, processing, completed, failed, skipped - `width` (integer) — -9007199254740991–9007199254740991 - `height` (integer) — -9007199254740991–9007199254740991 - `editedAt` (string) — date-time - `createdAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /messages/find-by-metadata:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/messages/find-by-metadata \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "metadata": {}, "limit": 20 }' ``` *Response 200 · example:* ```json { "data": [ { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sequence": "412", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "content": "Picking the auth refactor back up next sprint.", "replyCount": 2, "threadStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "clientMessageId": "ci-deploy-2.4.1", "sentVia": "…", "metadata": {}, "attachments": [ { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "width": 1, "height": 1 } ], "editedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z" } ] } ``` ### PATCH /api/v1/workspaces/{workspaceId}/messages/{messageId} **Update a message** Update a message you previously sent via API. Required scope: `messages:write` #### Path parameters - `messageId` (string, required) — Message ID (prefixed ULID) #### Request body - `content` (string, required) — min length 1 #### Response 200 - `data` (object, required) - `id` (string, required) - `streamId` (string, required) - `sequence` (string, required) — Numeric sequence as string - `authorId` (string, required) - `authorType` (string, required) — one of: user, persona, system, bot - `authorDisplayName` (string) - `content` (string, required) - `replyCount` (integer, required) — -9007199254740991–9007199254740991 - `threadStreamId` (string) - `clientMessageId` (string) - `sentVia` (string) — Present when message was sent via API on behalf of a user - `metadata` (object, required) — External references attached by the sender. Always present; empty when unset. - `attachments` (object[]) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `sizeBytes` (integer, required) — -9007199254740991–9007199254740991 - `processingStatus` (string) — one of: pending, processing, completed, failed, skipped - `width` (integer) — -9007199254740991–9007199254740991 - `height` (integer) — -9007199254740991–9007199254740991 - `editedAt` (string) — date-time - `createdAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *PATCH /messages/{messageId}:* ```bash curl -X PATCH https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/messages/MESSAGE_ID \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "string" }' ``` *Response 200 · example:* ```json { "data": { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sequence": "412", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "content": "Picking the auth refactor back up next sprint.", "replyCount": 2, "threadStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "clientMessageId": "ci-deploy-2.4.1", "sentVia": "…", "metadata": {}, "attachments": [ { "id": "msg_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "width": 1, "height": 1 } ], "editedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z" } } ``` ### DELETE /api/v1/workspaces/{workspaceId}/messages/{messageId} **Delete a message** Delete a message you previously sent via API. Required scope: `messages:write` #### Path parameters - `messageId` (string, required) — Message ID (prefixed ULID) #### Status codes - `204` No content - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *DELETE /messages/{messageId}:* ```bash curl -X DELETE https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/messages/MESSAGE_ID \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Conversations Browse first-class conversations — topic-level groupings of messages under a root stream and its threads ### GET /api/v1/workspaces/{workspaceId}/conversations **List conversations** Cursor-paginated conversation feed across accessible streams, newest activity first. Filter with \`streamId\` (scopes to that stream's root and its threads) and \`status\`. Required scope: `messages:read` #### Query parameters - `streamId` (string) - `status` (string) — one of: active, stalled, resolved - `after` (string) - `limit` (integer) — 1–100 · default 50 #### Response 200 - `data` (object[], required) - `id` (string, required) - `streamId` (string, required) — Anchor stream the conversation lives in (may be a thread) - `rootStreamId` (string, required) — Effective root of the anchor — the stream whose access governs the conversation - `topicSummary` (string | null, required) - `summary` (string | null, required) - `status` (string, required) — one of: active, stalled, resolved - `messageCount` (integer, required) — -9007199254740991–9007199254740991. Number of primary member messages - `participantIds` (string[], required) — Distinct author ids of the member messages - `lastActivityAt` (string, required) — date-time - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `hasMore` (boolean, required) - `cursor` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /conversations:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/conversations" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "topicSummary": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "summary": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "status": "active", "messageCount": 2, "participantIds": [ "…" ], "lastActivityAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z" } ], "hasMore": false, "cursor": null } ``` ### GET /api/v1/workspaces/{workspaceId}/conversations/{conversationId} **Get a conversation** Required scope: `messages:read` #### Path parameters - `conversationId` (string, required) — Conversation ID (prefixed ULID) #### Response 200 - `data` (object, required) - `id` (string, required) - `streamId` (string, required) — Anchor stream the conversation lives in (may be a thread) - `rootStreamId` (string, required) — Effective root of the anchor — the stream whose access governs the conversation - `topicSummary` (string | null, required) - `summary` (string | null, required) - `status` (string, required) — one of: active, stalled, resolved - `messageCount` (integer, required) — -9007199254740991–9007199254740991. Number of primary member messages - `participantIds` (string[], required) — Distinct author ids of the member messages - `lastActivityAt` (string, required) — date-time - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /conversations/{conversationId}:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/conversations/CONVERSATION_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "topicSummary": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "summary": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "status": "active", "messageCount": 2, "participantIds": [ "…" ], "lastActivityAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z" } } ``` ### GET /api/v1/workspaces/{workspaceId}/conversations/{conversationId}/messages **List a conversation's messages** The conversation's member messages in chronological order, cursor-paginated. Messages can span the conversation's root stream and its threads; each message carries its own \`streamId\`. Required scope: `messages:read` #### Path parameters - `conversationId` (string, required) — Conversation ID (prefixed ULID) #### Query parameters - `after` (string) - `limit` (integer) — 1–100 · default 50 #### Response 200 - `data` (object[], required) - `id` (string, required) - `streamId` (string, required) - `sequence` (string, required) — Numeric sequence as string - `authorId` (string, required) - `authorType` (string, required) — one of: user, persona, system, bot - `authorDisplayName` (string) - `content` (string, required) - `replyCount` (integer, required) — -9007199254740991–9007199254740991 - `threadStreamId` (string) - `clientMessageId` (string) - `sentVia` (string) — Present when message was sent via API on behalf of a user - `metadata` (object, required) — External references attached by the sender. Always present; empty when unset. - `attachments` (object[]) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `sizeBytes` (integer, required) — -9007199254740991–9007199254740991 - `processingStatus` (string) — one of: pending, processing, completed, failed, skipped - `width` (integer) — -9007199254740991–9007199254740991 - `height` (integer) — -9007199254740991–9007199254740991 - `editedAt` (string) — date-time - `createdAt` (string, required) — date-time - `hasMore` (boolean, required) - `cursor` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /conversations/{conversationId}/messages:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/conversations/CONVERSATION_ID/messages" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sequence": "412", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "content": "Picking the auth refactor back up next sprint.", "replyCount": 2, "threadStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "clientMessageId": "ci-deploy-2.4.1", "sentVia": "…", "metadata": {}, "attachments": [ { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "width": 1, "height": 1 } ], "editedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z" } ], "hasMore": false, "cursor": null } ``` ## Memos Search preserved workspace knowledge and inspect memo provenance Recipes [Find out what was decided, and why →](https://threa.io/developers/recipes.md#decisions) ### POST /api/v1/workspaces/{workspaceId}/memos/search **Search memos** Search preserved workspace memos with semantic, exact, or recent-first retrieval. Required scope: `memos:read` #### Request body - `query` (string, required) — default "" - `exact` (boolean) - `streams` (string[]) - `memoType` (string[]) - `knowledgeType` (string[]) - `tags` (string[]) - `scope` (string) — one of: user, stream, workspace - `before` (string) — date-time - `after` (string) — date-time - `limit` (integer, required) — 1–100 · default 20 #### Response 200 - `data` (object[], required) - `memo` (object, required) - `id` (string, required) - `workspaceId` (string, required) - `memoType` (string, required) — one of: message, conversation - `sourceMessageId` (string | null, required) - `sourceConversationId` (string | null, required) - `title` (string, required) - `abstract` (string, required) - `keyPoints` (string[], required) - `sourceMessageIds` (string[], required) - `participantIds` (string[], required) - `knowledgeType` (string, required) — one of: decision, learning, procedure, context, reference - `tags` (string[], required) - `parentMemoId` (string | null, required) - `status` (string, required) - `version` (integer, required) — -9007199254740991–9007199254740991 - `revisionReason` (string | null, required) - `authoredByKind` (string, required) — one of: pipeline, agent - `sourceSessionId` (string | null, required) - `scope` (string, required) — one of: user, stream, workspace - `scopeUserId` (string | null, required) - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `archivedAt` (string | null, required) - `distance` (number, required) - `sourceStream` (object | null, required) - `rootStream` (object | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /memos/search:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/memos/search \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "auth", "limit": 5 }' ``` *Response 200 · example:* ```json { "data": [ { "memo": { "id": "memo_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "memoType": "message", "sourceMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "sourceConversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n", "title": "Auth refactor held pending token-rotation review", "abstract": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "keyPoints": [ "…" ], "sourceMessageIds": [ "…" ], "participantIds": [ "…" ], "knowledgeType": "decision", "tags": [ "api-v3" ], "parentMemoId": "memo_01jd2q4z8kxw9v7r3m5t8n", "status": "available", "version": 1, "revisionReason": "…", "authoredByKind": "pipeline", "sourceSessionId": "session_01jd2q4z8kxw9v7r3m5t8n", "scope": "user", "scopeUserId": "usr_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" }, "distance": 1, "sourceStream": { "id": "memo_01jd2q4z8kxw9v7r3m5t8n", "type": "…", "name": "api-v3" }, "rootStream": { "id": "memo_01jd2q4z8kxw9v7r3m5t8n", "type": "…", "name": "api-v3" } } ] } ``` ### GET /api/v1/workspaces/{workspaceId}/memos/{memoId} **Get a memo** Retrieve a memo together with source stream and source message provenance. Required scope: `memos:read` #### Path parameters - `memoId` (string, required) — Memo ID (prefixed ULID) #### Response 200 - `data` (object, required) - `memo` (object, required) - `id` (string, required) - `workspaceId` (string, required) - `memoType` (string, required) — one of: message, conversation - `sourceMessageId` (string | null, required) - `sourceConversationId` (string | null, required) - `title` (string, required) - `abstract` (string, required) - `keyPoints` (string[], required) - `sourceMessageIds` (string[], required) - `participantIds` (string[], required) - `knowledgeType` (string, required) — one of: decision, learning, procedure, context, reference - `tags` (string[], required) - `parentMemoId` (string | null, required) - `status` (string, required) - `version` (integer, required) — -9007199254740991–9007199254740991 - `revisionReason` (string | null, required) - `authoredByKind` (string, required) — one of: pipeline, agent - `sourceSessionId` (string | null, required) - `scope` (string, required) — one of: user, stream, workspace - `scopeUserId` (string | null, required) - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `archivedAt` (string | null, required) - `distance` (number, required) - `sourceStream` (object | null, required) - `rootStream` (object | null, required) - `sourceMessages` (object[], required) - `id` (string, required) - `streamId` (string, required) - `streamName` (string, required) - `authorId` (string, required) - `authorType` (string, required) — one of: user, persona, system, bot - `authorName` (string, required) - `content` (string, required) - `createdAt` (string, required) — date-time - `successorMemoId` (string | null, required) - `capturedByPersonaName` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /memos/{memoId}:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/memos/MEMO_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "memo": { "id": "memo_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "memoType": "message", "sourceMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "sourceConversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n", "title": "Auth refactor held pending token-rotation review", "abstract": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "keyPoints": [ "…" ], "sourceMessageIds": [ "…" ], "participantIds": [ "…" ], "knowledgeType": "decision", "tags": [ "api-v3" ], "parentMemoId": "memo_01jd2q4z8kxw9v7r3m5t8n", "status": "available", "version": 1, "revisionReason": "…", "authoredByKind": "pipeline", "sourceSessionId": "session_01jd2q4z8kxw9v7r3m5t8n", "scope": "user", "scopeUserId": "usr_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" }, "distance": 1, "sourceStream": { "id": "memo_01jd2q4z8kxw9v7r3m5t8n", "type": "…", "name": "api-v3" }, "rootStream": { "id": "memo_01jd2q4z8kxw9v7r3m5t8n", "type": "…", "name": "api-v3" }, "sourceMessages": [ { "id": "memo_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "streamName": "…", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorName": "…", "content": "Picking the auth refactor back up next sprint.", "createdAt": "2026-03-12T11:42:00.000Z" } ], "successorMemoId": "memo_01jd2q4z8kxw9v7r3m5t8n", "capturedByPersonaName": "…" } } ``` ## Attachments Search attachments, inspect extracted content, and fetch download URLs ### POST /api/v1/workspaces/{workspaceId}/attachments **Upload an attachment** Upload a file as multipart/form-data using field \`file\`. Include the returned attachment id in message markdown as \`attachment:\` to attach it to a message. Required scope: `attachments:write` #### Response 201 - `data` (object, required) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `sizeBytes` (integer, required) — -9007199254740991–9007199254740991 - `processingStatus` (string, required) — one of: pending, processing, completed, failed, skipped - `createdAt` (string, required) — date-time #### Status codes - `201` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /attachments:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/attachments \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 201 · example:* ```json { "data": { "id": "attach_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "createdAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/attachments/search **Search attachments** Search accessible attachments by filename or extracted content. Required scope: `attachments:read` #### Request body - `query` (string, required) — min length 1 - `streams` (string[]) - `contentTypes` (string[]) - `limit` (integer, required) — 1–50 · default 20 #### Response 200 - `data` (object[], required) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `contentType` (string | null, required) - `summary` (string | null, required) - `streamId` (string) - `messageId` (string) - `createdAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /attachments/search:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/attachments/search \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "auth", "limit": 5 }' ``` *Response 200 · example:* ```json { "data": [ { "id": "attach_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "contentType": "chart", "summary": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "messageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z" } ] } ``` ### GET /api/v1/workspaces/{workspaceId}/attachments/{attachmentId} **Get an attachment** Retrieve attachment metadata and extracted content for an accessible attachment. Required scope: `attachments:read` #### Path parameters - `attachmentId` (string, required) — Attachment ID (prefixed ULID) #### Response 200 - `data` (object, required) - `id` (string, required) - `filename` (string, required) - `mimeType` (string, required) - `sizeBytes` (integer, required) — -9007199254740991–9007199254740991 - `processingStatus` (string, required) — one of: pending, processing, completed, failed, skipped - `createdAt` (string, required) — date-time - `extraction` (object | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /attachments/{attachmentId}:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/attachments/ATTACHMENT_ID" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "id": "attach_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "createdAt": "2026-03-12T11:42:00.000Z", "extraction": { "contentType": "chart", "summary": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "fullText": "Picking the auth refactor back up next sprint.", "structuredData": null, "pdfMetadata": null, "textMetadata": null, "wordMetadata": null, "excelMetadata": null } } } ``` ### GET /api/v1/workspaces/{workspaceId}/attachments/{attachmentId}/url **Get an attachment download URL** Create a short-lived signed URL for an accessible attachment. Required scope: `attachments:read` #### Path parameters - `attachmentId` (string, required) — Attachment ID (prefixed ULID) #### Response 200 - `data` (object, required) - `url` (string, required) — uri - `expiresIn` (integer, required) — -9007199254740991–9007199254740991 #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /attachments/{attachmentId}/url:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/attachments/ATTACHMENT_ID/url" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "url": "https://files.threa.io/attachments/rotation-flow.png", "expiresIn": 1 } } ``` ## Users List workspace users ### GET /api/v1/workspaces/{workspaceId}/users **List workspace users** List users in the workspace with optional text search and cursor pagination. Required scope: `users:read` #### Query parameters - `query` (string) - `after` (string) - `limit` (integer) — 1–200 · default 50 #### Response 200 - `data` (object[], required) - `id` (string, required) - `name` (string, required) - `slug` (string, required) - `email` (string, required) - `avatarUrl` (string) - `role` (string, required) - `hasMore` (boolean, required) - `cursor` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /users:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/users?limit=20" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "id": "usr_01jd2q4z8kxw9v7r3m5t8n", "name": "Maya Reyes", "slug": "api-v3", "email": "maya@acme.dev", "avatarUrl": "https://files.threa.io/attachments/rotation-flow.png", "role": "member" } ], "hasMore": false, "cursor": null } ``` ## Labels List, create, edit, archive, join, and apply workspace labels ### GET /api/v1/workspaces/{workspaceId}/labels **List labels** The key actor's labels (every label is private to its owner) and their resource assignments. Required scope: `labels:read` #### Response 200 - `data` (object, required) - `labels` (object[], required) - `id` (string, required) - `workspaceId` (string, required) - `creatorActorType` (string, required) — one of: user, bot - `creatorActorId` (string, required) - `name` (string, required) - `slug` (string, required) - `color` (string, required) - `emoji` (string | null, required) - `description` (string | null, required) - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `archivedAt` (string | null, required) - `assignments` (object[], required) - `labelId` (string, required) - `resourceType` (string, required) — one of: stream, message - `resourceId` (string, required) - `actorType` (string, required) — one of: user, bot - `actorId` (string, required) - `workspaceId` (string, required) - `assignedAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /labels:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/labels" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "labels": [ { "id": "label_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "creatorActorType": "user", "creatorActorId": "creatoractor_01jd2q4z8kxw9v7r3m5t8n", "name": "api-v3", "slug": "api-v3", "color": "…", "emoji": "…", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" } ], "assignments": [ { "labelId": "label_01jd2q4z8kxw9v7r3m5t8n", "resourceType": "stream", "resourceId": "resource_01jd2q4z8kxw9v7r3m5t8n", "actorType": "user", "actorId": "actor_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "assignedAt": "2026-03-12T11:42:00.000Z" } ] } } ``` ### POST /api/v1/workspaces/{workspaceId}/labels **Create or update a label by name** Find-or-create a label owned by the key actor (a user or a bot), keyed by its name. Posting an existing name returns that label and applies any appearance fields supplied; labels are identified by their text, so this is idempotent. Required scope: `labels:write` #### Request body - `name` (string, required) — min length 1 · max length 100 - `color` (string) - `emoji` (string | null) - `description` (string | null) #### Response 201 - `data` (object, required) - `id` (string, required) - `workspaceId` (string, required) - `creatorActorType` (string, required) — one of: user, bot - `creatorActorId` (string, required) - `name` (string, required) - `slug` (string, required) - `color` (string, required) - `emoji` (string | null, required) - `description` (string | null, required) - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `archivedAt` (string | null, required) #### Status codes - `201` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /labels:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/labels \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "string" }' ``` *Response 201 · example:* ```json { "data": { "id": "label_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "creatorActorType": "user", "creatorActorId": "creatoractor_01jd2q4z8kxw9v7r3m5t8n", "name": "api-v3", "slug": "api-v3", "color": "…", "emoji": "…", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/labels/assignments **Apply a label to a resource by name** Attach a label to a resource the key actor can reach, identifying the label by its text: the label is found-or-created for the actor, then assigned. \`resourceType\` is the polymorphic target (\`stream\` today) so the same endpoint labels any future resource without a wire change. Required scope: `labels:write` #### Request body - `name` (string, required) — min length 1 · max length 100 - `color` (string) - `emoji` (string | null) - `description` (string | null) - `resourceType` (string, required) — one of: stream, message - `resourceId` (string, required) — min length 1 · max length 64 #### Response 201 - `data` (object, required) - `label` (object, required) - `id` (string, required) - `workspaceId` (string, required) - `creatorActorType` (string, required) — one of: user, bot - `creatorActorId` (string, required) - `name` (string, required) - `slug` (string, required) - `color` (string, required) - `emoji` (string | null, required) - `description` (string | null, required) - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `archivedAt` (string | null, required) - `assignment` (object, required) - `labelId` (string, required) - `resourceType` (string, required) — one of: stream, message - `resourceId` (string, required) - `actorType` (string, required) — one of: user, bot - `actorId` (string, required) - `workspaceId` (string, required) - `assignedAt` (string, required) — date-time #### Status codes - `201` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /labels/assignments:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/labels/assignments \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "resourceType": "stream", "resourceId": "string" }' ``` *Response 201 · example:* ```json { "data": { "label": { "id": "label_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "creatorActorType": "user", "creatorActorId": "creatoractor_01jd2q4z8kxw9v7r3m5t8n", "name": "api-v3", "slug": "api-v3", "color": "…", "emoji": "…", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" }, "assignment": { "labelId": "label_01jd2q4z8kxw9v7r3m5t8n", "resourceType": "stream", "resourceId": "resource_01jd2q4z8kxw9v7r3m5t8n", "actorType": "user", "actorId": "actor_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "assignedAt": "2026-03-12T11:42:00.000Z" } } } ``` ### DELETE /api/v1/workspaces/{workspaceId}/labels/assignments **Remove a label from a resource by name** Remove the key actor's assignment of a label (identified by its text) from a resource. Required scope: `labels:write` #### Query parameters - `name` (string, required) — min length 1 · max length 100 - `resourceType` (string, required) — one of: stream, message - `resourceId` (string, required) — min length 1 · max length 64 #### Status codes - `204` No content - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *DELETE /labels/assignments:* ```bash curl -X DELETE https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/labels/assignments?name=NAME&resourceType=stream&resourceId=RESOURCEID \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### PATCH /api/v1/workspaces/{workspaceId}/labels/{labelId} **Update a label** Update a label the key actor created. Required scope: `labels:write` #### Path parameters - `labelId` (string, required) — Label ID (prefixed ULID) #### Request body - `name` (string) — min length 1 · max length 100 - `color` (string) - `emoji` (string | null) - `description` (string | null) #### Response 200 - `data` (object, required) - `id` (string, required) - `workspaceId` (string, required) - `creatorActorType` (string, required) — one of: user, bot - `creatorActorId` (string, required) - `name` (string, required) - `slug` (string, required) - `color` (string, required) - `emoji` (string | null, required) - `description` (string | null, required) - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time - `archivedAt` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *PATCH /labels/{labelId}:* ```bash curl -X PATCH https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/labels/LABEL_ID \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "string", "color": "string", "emoji": null }' ``` *Response 200 · example:* ```json { "data": { "id": "label_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "creatorActorType": "user", "creatorActorId": "creatoractor_01jd2q4z8kxw9v7r3m5t8n", "name": "api-v3", "slug": "api-v3", "color": "…", "emoji": "…", "description": "Jordan asked to pause the v3 auth boundary until the rotation flow is reviewed.", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z", "archivedAt": "2026-03-12T11:42:00.000Z" } } ``` ### DELETE /api/v1/workspaces/{workspaceId}/labels/{labelId} **Delete a label** Archive a label the key actor created and remove its assignments. Required scope: `labels:write` #### Path parameters - `labelId` (string, required) — Label ID (prefixed ULID) #### Status codes - `204` No content - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *DELETE /labels/{labelId}:* ```bash curl -X DELETE https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/labels/LABEL_ID \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Bot runtimes Register a runtime and keep its presence alive so it can be assigned work. Recipes [Connect your local agent →](https://threa.io/developers/recipes.md#local-agent) ### POST /api/v1/workspaces/{workspaceId}/bot-runtime/presence **Heartbeat bot runtime presence** Required scope: `bot-runtime:write` #### Request body - `runtimeKind` (string, required) — one of: pi-local, hermes, openclaw, claude-code-channel, custom - `instanceId` (string, required) — min length 1 · max length 128 - `runtimeSessionId` (string) — min length 1 · max length 256 - `displayName` (string) — max length 100 - `status` (string, required) — one of: available, busy, offline, error - `acceptingInvocations` (boolean, required) - `capabilities` (object, required) — default {} - `statusText` (string) — max length 200 - `publicKey` (string) — min length 44 · max length 44 - `publicKeyId` (string) — min length 1 · max length 128 #### Response 200 - `data` (object, required) - `id` (string, required) - `workspaceId` (string, required) - `botId` (string, required) - `runtimeKind` (string, required) — one of: pi-local, hermes, openclaw, claude-code-channel, custom - `instanceId` (string, required) - `displayName` (string | null, required) - `status` (string, required) — one of: available, busy, offline, error - `acceptingInvocations` (boolean, required) - `capabilities` (object, required) - `statusText` (string | null, required) - `lastSeenAt` (string, required) — date-time - `createdAt` (string, required) — date-time - `updatedAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /bot-runtime/presence:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-runtime/presence \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "runtimeKind": "pi-local", "instanceId": "string", "status": "available", "acceptingInvocations": false, "capabilities": {} }' ``` *Response 200 · example:* ```json { "data": { "id": "bot_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "botId": "bot_01jd2q4z8kxw9v7r3m5t8n", "runtimeKind": "pi-local", "instanceId": "my-laptop-1", "displayName": "Maya Reyes", "status": "available", "acceptingInvocations": true, "capabilities": {}, "statusText": "Picking the auth refactor back up next sprint.", "lastSeenAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z", "updatedAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-runtime/sessions **Create or link a bot runtime session** Required scope: `bot-runtime:write` #### Request body - `runtimeKind` (string, required) — one of: pi-local, claude-code-channel - `instanceId` (string, required) — min length 1 · max length 128 - `runtimeSessionId` (string, required) — min length 1 · max length 256 - `displayName` (string, required) — min length 1 · max length 100 - `localCwd` (string) — max length 1000 - `memoryMode` (string) — one of: auto, off - `labelName` (string) — min length 1 · max length 100 - `description` (string) — max length 10000 - `e2e` (object) - `ownerKeyId` (string, required) — min length 1 · max length 128 - `ifArchived` (string) — one of: wait, replace #### Response 200 - `data` (object, required) - `linkId` (string, required) - `rootStreamId` (string, required) - `activeStreamId` (string, required) - `runtimeSessionId` (string, required) - `streamUrlPath` (string, required) - `e2eEnabled` (boolean) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /bot-runtime/sessions:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-runtime/sessions \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "runtimeKind": "pi-local", "instanceId": "string", "runtimeSessionId": "string", "displayName": "string" }' ``` *Response 200 · example:* ```json { "data": { "linkId": "link_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "activeStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "runtimeSessionId": "runtimesession_01jd2q4z8kxw9v7r3m5t8n", "streamUrlPath": "https://files.threa.io/attachments/rotation-flow.png", "e2eEnabled": true } } ``` ### GET /api/v1/workspaces/{workspaceId}/bot-runtime/owner-e2e-key **Get the bot owner's active encryption public key** The bot owner's active UIK (key id + base64 X25519 public key). A sealed harness fetches this before creating an end-to-end-encrypted session so it can wrap the generation-0 stream key to the owner. 404 when the owner has not set up encryption. Required scope: `bot-runtime:write` #### Response 200 - `data` (object, required) - `keyId` (string, required) - `publicKey` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *GET /bot-runtime/owner-e2e-key:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-runtime/owner-e2e-key" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "keyId": "key_01jd2q4z8kxw9v7r3m5t8n", "publicKey": "…" } } ``` ### POST /api/v1/workspaces/{workspaceId}/streams/{streamId}/e2e/key-wraps **Provision the generation-0 key wraps for a harness-created encrypted scratchpad** Phase two of harness-created E2E scratchpads: stores the stream-key wraps (owner UIK + the harness's own BIK) minted against the stream id returned by session create. Bot-actor-only, current generation only, and only while the generation has no wraps. Slots are immutable, so a replay cannot splice keys. Required scope: `bot-runtime:write` #### Path parameters - `streamId` (string, required) — Stream ID #### Request body - `keyGeneration` (integer, required) — 0–9007199254740991 - `wraps` (object[], required) - `recipientKind` (string, required) — one of: user, bot - `recipientKeyId` (string, required) — min length 1 · max length 128 - `wrapEnc` (string, required) — base64 · min length 1 - `wrapCt` (string, required) — base64 · min length 1 #### Response 200 - `data` (object, required) - `stored` (integer, required) — -9007199254740991–9007199254740991 #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /streams/{streamId}/e2e/key-wraps:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/e2e/key-wraps \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "keyGeneration": 0, "wraps": [ { "recipientKind": "user", "recipientKeyId": "string", "wrapEnc": "string", "wrapCt": "string" } ] }' ``` *Response 200 · example:* ```json { "data": { "stored": 1 } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-runtime/sessions/rename **Rename the scratchpad linked to a bot runtime session** Required scope: `bot-runtime:write` #### Request body - `instanceId` (string, required) — min length 1 · max length 128 - `runtimeSessionId` (string, required) — min length 1 · max length 256 - `displayName` (string, required) — min length 1 · max length 100 #### Response 200 - `data` (object, required) - `linkId` (string, required) - `rootStreamId` (string, required) - `activeStreamId` (string, required) - `runtimeSessionId` (string, required) - `streamUrlPath` (string, required) - `e2eEnabled` (boolean) - `displayName` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-runtime/sessions/rename:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-runtime/sessions/rename \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "instanceId": "string", "runtimeSessionId": "string", "displayName": "string" }' ``` *Response 200 · example:* ```json { "data": { "linkId": "link_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "activeStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "runtimeSessionId": "runtimesession_01jd2q4z8kxw9v7r3m5t8n", "streamUrlPath": "https://files.threa.io/attachments/rotation-flow.png", "e2eEnabled": true, "displayName": "Maya Reyes" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-runtime/sessions/rebind **Move an existing bot runtime session link to a new runtime instance id** Required scope: `bot-runtime:write` #### Request body - `linkId` (string, required) — min length 1 · max length 128 - `instanceId` (string, required) — min length 1 · max length 128 - `runtimeSessionId` (string, required) — min length 1 · max length 256 - `newInstanceId` (string, required) — min length 1 · max length 128 #### Response 200 - `data` (object, required) - `linkId` (string, required) - `rootStreamId` (string, required) - `activeStreamId` (string, required) - `runtimeSessionId` (string, required) - `streamUrlPath` (string, required) - `e2eEnabled` (boolean) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-runtime/sessions/rebind:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-runtime/sessions/rebind \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "linkId": "string", "instanceId": "string", "runtimeSessionId": "string", "newInstanceId": "string" }' ``` *Response 200 · example:* ```json { "data": { "linkId": "link_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "activeStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "runtimeSessionId": "runtimesession_01jd2q4z8kxw9v7r3m5t8n", "streamUrlPath": "https://files.threa.io/attachments/rotation-flow.png", "e2eEnabled": true } } ``` ## Bot invocations Claim, renew, step through, complete, or fail the work a bot is summoned to do. Recipes [Connect your local agent →](https://threa.io/developers/recipes.md#local-agent) ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/claim **Claim one pending bot invocation** Required scope: `bot-invocations:write` #### Request body - `runtimeKind` (string, required) — one of: pi-local, hermes, openclaw, claude-code-channel, custom - `instanceId` (string, required) — min length 1 · max length 128 - `runtimeSessionId` (string) — min length 1 · max length 256 - `supportedCapabilities` (string[], required) - `claimTtlSeconds` (integer, required) — 15–300 · default 60 #### Response 200 - `data` (object | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *POST /bot-invocations/claim:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/claim \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "runtimeKind": "pi-local", "instanceId": "string", "supportedCapabilities": [ "mentionable" ], "claimTtlSeconds": 60 }' ``` *Response 200 · example:* ```json { "data": { "id": "inv_01jd2q4z8kxw9v7r3m5t8n", "workspaceId": "ws_01jd2q4z8kxw9v7r3m5t8n", "rootStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "activeStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sourceMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "responseStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "actor": { "type": "…", "id": "inv_01jd2q4z8kxw9v7r3m5t8n", "slug": "api-v3" }, "trigger": "mention", "requiredCapability": "mentionable", "promptMarkdown": "Picking the auth refactor back up next sprint.", "authorUserId": "usr_01jd2q4z8kxw9v7r3m5t8n", "mentionedActorSlugs": [ "api-v3" ], "claimToken": "clm_7f3kq9w2…", "claimExpiresAt": "2026-03-12T11:42:00.000Z", "runtimeSessionId": "runtimesession_01jd2q4z8kxw9v7r3m5t8n", "metadata": {}, "context": { "kind": "user", "messages": [ { "messageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "role": "user", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "contentMarkdown": "Picking the auth refactor back up next sprint.", "createdAt": "2026-03-12T11:42:00.000Z" } ] }, "sealedContext": { "callbackToken": "clm_7f3kq9w2…", "wraps": [ { "keyGeneration": 1, "wrapEnc": "…", "wrapCt": "…" } ], "history": [ { "ciphertext": "Picking the auth refactor back up next sprint.", "envelope": { "v": 1, "keyGeneration": 1, "iv": "…", "aad": "…" }, "role": "user", "sequence": "412" } ], "prompt": { "ciphertext": "Picking the auth refactor back up next sprint.", "envelope": { "v": 1, "keyGeneration": 1, "iv": "…", "aad": "…" } }, "reply": { "keyGeneration": 1, "senderId": "sender_01jd2q4z8kxw9v7r3m5t8n" }, "trigger": { "messageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "authorName": "…", "authorType": "user", "createdAt": "2026-03-12T11:42:00.000Z" } } } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/renew **Renew a claimed bot invocation** Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `instanceId` (string, required) — min length 1 · max length 128 - `claimToken` (string, required) — min length 1 · max length 256 - `claimTtlSeconds` (integer, required) — 15–300 · default 60 #### Response 200 - `data` (object, required) - `invocationId` (string, required) - `status` (string, required) - `claimExpiresAt` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/renew:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/renew \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "instanceId": "string", "claimToken": "string", "claimTtlSeconds": 60 }' ``` *Response 200 · example:* ```json { "data": { "invocationId": "inv_01jd2q4z8kxw9v7r3m5t8n", "status": "available", "claimExpiresAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/steps **Record a bot invocation trace step** Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `instanceId` (string, required) — min length 1 · max length 128 - `claimToken` (string, required) — min length 1 · max length 256 - `stepType` (string, required) — one of: context_received, thinking, reconsidering, steer, web_search, visit_page, workspace_search, research, github_access, linear_access, message_sent, message_edited, response, tool_call, tool_error, rate_limited, rate_limit_retry, turn_digest, model_escalated - `content` (string, required) — min length 1 · max length 10000 - `statusText` (string) — max length 200 - `clientStepId` (string) — min length 1 · max length 128 #### Response 200 - `data` (object, required) - `invocationId` (string, required) - `sessionId` (string, required) - `stepId` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/steps:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/steps \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "instanceId": "string", "claimToken": "string", "stepType": "context_received", "content": "string" }' ``` *Response 200 · example:* ```json { "data": { "invocationId": "inv_01jd2q4z8kxw9v7r3m5t8n", "sessionId": "session_01jd2q4z8kxw9v7r3m5t8n", "stepId": "step_01jd2q4z8kxw9v7r3m5t8n" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/sealed-steps/started **Open an in-flight sealed bot invocation trace step** Sealed variant of the trace-step start, for an owner-granted E2E bot harness: the content is ciphertext the server never decrypts. Authenticated with the per-claim callback token in the X-Threa-Callback-Token header. Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `stepId` (string, required) — min length 1 · max length 128 - `stepType` (string, required) — one of: context_received, thinking, reconsidering, steer, web_search, visit_page, workspace_search, research, github_access, linear_access, message_sent, message_edited, response, tool_call, tool_error, rate_limited, rate_limit_retry, turn_digest, model_escalated - `messageId` (string) — min length 1 · max length 128 - `ciphertext` (string) — base64 · min length 1 - `envelope` (object) - `v` (number, required) - `keyGeneration` (integer, required) — 0–9007199254740991 - `iv` (string, required) — base64 · min length 1 - `aad` (string, required) — base64 · min length 1 #### Response 200 - `data` (object, required) - `invocationId` (string, required) - `sessionId` (string, required) - `stepId` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/sealed-steps/started:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/sealed-steps/started \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "stepId": "string", "stepType": "context_received" }' ``` *Response 200 · example:* ```json { "data": { "invocationId": "inv_01jd2q4z8kxw9v7r3m5t8n", "sessionId": "session_01jd2q4z8kxw9v7r3m5t8n", "stepId": "step_01jd2q4z8kxw9v7r3m5t8n" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/sealed-steps **Finalize a sealed bot invocation trace step** Sealed variant of the trace-step finalize, for an owner-granted E2E bot harness: sets the sealed content + completion on the step opened at sealed-steps/started (or inserts a completed row if the start was dropped). Authenticated with the per-claim callback token in the X-Threa-Callback-Token header. Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `stepId` (string, required) — min length 1 · max length 128 - `stepType` (string, required) — one of: context_received, thinking, reconsidering, steer, web_search, visit_page, workspace_search, research, github_access, linear_access, message_sent, message_edited, response, tool_call, tool_error, rate_limited, rate_limit_retry, turn_digest, model_escalated - `messageId` (string) — min length 1 · max length 128 - `ciphertext` (string, required) — base64 · min length 1 - `envelope` (object, required) - `v` (number, required) - `keyGeneration` (integer, required) — 0–9007199254740991 - `iv` (string, required) — base64 · min length 1 - `aad` (string, required) — base64 · min length 1 - `durationMs` (integer) — 0–9007199254740991 #### Response 200 - `data` (object, required) - `invocationId` (string, required) - `sessionId` (string, required) - `stepId` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/sealed-steps:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/sealed-steps \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "stepId": "string", "stepType": "context_received", "ciphertext": "string", "envelope": { "v": 1, "keyGeneration": 0, "iv": "string", "aad": "string" } }' ``` *Response 200 · example:* ```json { "data": { "invocationId": "inv_01jd2q4z8kxw9v7r3m5t8n", "sessionId": "session_01jd2q4z8kxw9v7r3m5t8n", "stepId": "step_01jd2q4z8kxw9v7r3m5t8n" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/sealed-messages **Post a sealed interim message from an in-flight sealed bot invocation** Sealed variant of a mid-turn bot message, for an owner-granted E2E bot harness: posts one sealed interim message (ciphertext the server never decrypts) into the claim's stream before the turn completes: progress notes, permission prompts, early acks. The client-minted messageId binds the seal AAD and dedupes retries. Authenticated with the per-claim callback token in the X-Threa-Callback-Token header. Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `messageId` (string, required) — min length 1 · max length 128 - `ciphertext` (string, required) — base64 · min length 1 - `envelope` (object, required) - `v` (number, required) - `keyGeneration` (integer, required) — 0–9007199254740991 - `iv` (string, required) — base64 · min length 1 - `aad` (string, required) — base64 · min length 1 - `attachmentIds` (string[]) #### Response 200 - `data` (object, required) - `messageId` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/sealed-messages:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/sealed-messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "messageId": "string", "ciphertext": "string", "envelope": { "v": 1, "keyGeneration": 0, "iv": "string", "aad": "string" } }' ``` *Response 200 · example:* ```json { "data": { "messageId": "msg_01jd2q4z8kxw9v7r3m5t8n" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/sealed-complete **Complete a sealed bot invocation** Sealed variant of the completion, for an owner-granted E2E bot harness: persists the turn's final sealed reply (ciphertext the server never decrypts) or noResponse, flips the claim, and finalizes the agent session. Authenticated with the per-claim callback token in the X-Threa-Callback-Token header. Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `reply` (object) - `messageId` (string, required) — min length 1 · max length 128 - `ciphertext` (string, required) — base64 · min length 1 - `envelope` (object, required) - `v` (number, required) - `keyGeneration` (integer, required) — 0–9007199254740991 - `iv` (string, required) — base64 · min length 1 - `aad` (string, required) — base64 · min length 1 - `attachmentIds` (string[]) - `noResponse` (boolean) #### Response 200 - `data` (object, required) - `invocationId` (string, required) - `sessionId` (string, required) - `messageId` (string | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/sealed-complete:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/sealed-complete \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "reply": { "messageId": "string", "ciphertext": "string", "envelope": { "v": 1, "keyGeneration": 0, "iv": "string", "aad": "string" } }, "noResponse": false }' ``` *Response 200 · example:* ```json { "data": { "invocationId": "inv_01jd2q4z8kxw9v7r3m5t8n", "sessionId": "session_01jd2q4z8kxw9v7r3m5t8n", "messageId": "msg_01jd2q4z8kxw9v7r3m5t8n" } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/complete **Complete a claimed bot invocation** Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `instanceId` (string, required) — min length 1 · max length 128 - `claimToken` (string, required) — min length 1 · max length 256 - `finalMessageMarkdown` (string) — min length 1 · max length 50000 - `noResponse` (boolean) - `sources` (object[]) - `type` (string) — one of: web, workspace, github - `title` (string, required) — min length 1 · max length 500 - `url` (string, required) — min length 1 · max length 2000 - `snippet` (string) — max length 2000 - `metadata` (object) - `sealedReply` (object) - `messageId` (string, required) — min length 1 · max length 128 - `ciphertext` (string, required) — base64 · min length 1 - `envelope` (object, required) - `v` (number, required) - `keyGeneration` (integer, required) — 0–9007199254740991 - `iv` (string, required) — base64 · min length 1 - `aad` (string, required) — base64 · min length 1 #### Response 200 - `data` (object, required) - `invocationId` (string, required) - `message` (object | null, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/complete:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/complete \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "instanceId": "string", "claimToken": "string" }' ``` *Response 200 · example:* ```json { "data": { "invocationId": "inv_01jd2q4z8kxw9v7r3m5t8n", "message": { "id": "inv_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "sequence": "412", "authorId": "usr_01jd2q4z8kxw9v7r3m5t8n", "authorType": "user", "authorDisplayName": "Maya Reyes", "content": "Picking the auth refactor back up next sprint.", "replyCount": 2, "threadStreamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "clientMessageId": "ci-deploy-2.4.1", "sentVia": "…", "metadata": {}, "attachments": [ { "id": "inv_01jd2q4z8kxw9v7r3m5t8n", "filename": "rotation-flow.png", "mimeType": "image/png", "sizeBytes": 48213, "processingStatus": "pending", "width": 1, "height": 1 } ], "editedAt": "2026-03-12T11:42:00.000Z", "createdAt": "2026-03-12T11:42:00.000Z" } } } ``` ### POST /api/v1/workspaces/{workspaceId}/bot-invocations/{invocationId}/fail **Fail a claimed bot invocation** Required scope: `bot-invocations:write` #### Path parameters - `invocationId` (string, required) — Invocation ID #### Request body - `instanceId` (string, required) — min length 1 · max length 128 - `claimToken` (string, required) — min length 1 · max length 256 - `errorMessage` (string, required) — min length 1 · max length 1000 #### Response 200 - `data` (object, required) - `invocationId` (string, required) - `status` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /bot-invocations/{invocationId}/fail:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-invocations/INVOCATION_ID/fail \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "instanceId": "string", "claimToken": "string", "errorMessage": "string" }' ``` *Response 200 · example:* ```json { "data": { "invocationId": "inv_01jd2q4z8kxw9v7r3m5t8n", "status": "available" } } ``` ## Delegations Close the loop on delegated tasks: your local agent lists the open queue, claims a task, reports progress, and completes it with a result posted back to the stream. ### GET /api/v1/workspaces/{workspaceId}/delegations **List open delegations** List delegated tasks that are open to claim, filtered to what the key can access: a user-scoped key sees streams its user can access, a workspace key sees the bot's channel grants. Required scope: `delegations:read` #### Query parameters - `status` (string) — default "open" - `since` (string) — date-time #### Response 200 - `data` (object[], required) - `id` (string, required) - `streamId` (string, required) - `title` (string, required) - `status` (string, required) — one of: open, claimed, running, completed, failed, cancelled, expired - `claimedByLabel` (string) - `statusNote` (string) - `resultMessageId` (string) - `sourceConversationId` (string) - `createdAt` (string, required) — date-time - `statusChangedAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource *GET /delegations:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": [ { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "title": "Auth refactor held pending token-rotation review", "status": "open", "claimedByLabel": "…", "statusNote": "available", "resultMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "sourceConversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "statusChangedAt": "2026-03-12T11:42:00.000Z" } ] } ``` ### POST /api/v1/workspaces/{workspaceId}/delegations/{delegationId}/claim **Claim an open delegation** Atomically claim an open delegation. Returns the brief, context refs, and the claim token (cleartext, exactly once; send it as X-Threa-Callback-Token on every later lifecycle call). A delegation that is no longer open returns 409. Required scope: `delegations:write` #### Path parameters - `delegationId` (string, required) — Delegation ID (prefixed ULID) #### Request body - `claimedByLabel` (string, required) — min length 1 · max length 200 - `idempotencyKey` (string) — min length 8 · max length 128 #### Response 200 - `data` (object, required) - `id` (string, required) - `streamId` (string, required) - `title` (string, required) - `status` (string, required) — one of: open, claimed, running, completed, failed, cancelled, expired - `claimedByLabel` (string) - `statusNote` (string) - `resultMessageId` (string) - `sourceConversationId` (string) - `createdAt` (string, required) — date-time - `statusChangedAt` (string, required) — date-time - `brief` (string, required) - `contextRefs` (string[], required) - `claimToken` (string, required) - `claimExpiresAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found - `409` Conflict: the resource is not in the state the operation requires *POST /delegations/{delegationId}/claim:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/claim \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "claimedByLabel": "string" }' ``` *Response 200 · example:* ```json { "data": { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "title": "Auth refactor held pending token-rotation review", "status": "open", "claimedByLabel": "…", "statusNote": "available", "resultMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "sourceConversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "statusChangedAt": "2026-03-12T11:42:00.000Z", "brief": "…", "contextRefs": [ "Picking the auth refactor back up next sprint." ], "claimToken": "clm_7f3kq9w2…", "claimExpiresAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/delegations/{delegationId}/heartbeat **Renew a delegation claim** Push the claim's expiry forward while the local agent is still working. Liveness only; no status change on the card. Authenticated with the per-claim token in the X-Threa-Callback-Token header. Required scope: `delegations:write` #### Path parameters - `delegationId` (string, required) — Delegation ID (prefixed ULID) #### Response 200 - `data` (object, required) - `claimExpiresAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /delegations/{delegationId}/heartbeat:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/heartbeat \ -H "Authorization: Bearer YOUR_API_KEY" ``` *Response 200 · example:* ```json { "data": { "claimExpiresAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/delegations/{delegationId}/status **Report delegation progress** Mark the delegation running and put a free-text progress note on its card (each report replaces the previous note; the claim TTL renews). Authenticated with the per-claim token in the X-Threa-Callback-Token header. Required scope: `delegations:write` #### Path parameters - `delegationId` (string, required) — Delegation ID (prefixed ULID) #### Request body - `statusNote` (string) — min length 1 · max length 2000 #### Response 200 - `data` (object, required) - `id` (string, required) - `streamId` (string, required) - `title` (string, required) - `status` (string, required) — one of: open, claimed, running, completed, failed, cancelled, expired - `claimedByLabel` (string) - `statusNote` (string) - `resultMessageId` (string) - `sourceConversationId` (string) - `createdAt` (string, required) — date-time - `statusChangedAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /delegations/{delegationId}/status:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/status \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "statusNote": "string" }' ``` *Response 200 · example:* ```json { "data": { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "title": "Auth refactor held pending token-rotation review", "status": "open", "claimedByLabel": "…", "statusNote": "available", "resultMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "sourceConversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "statusChangedAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/delegations/{delegationId}/complete **Complete a delegation** Complete the claimed delegation. When resultMarkdown is given, the result is posted to the delegation's stream in the same transaction as the completion, authored as the key's user (with via-API provenance) for a user-scoped key, or as the bot for a workspace key. It enters the normal message pipeline, so workspace memory captures the outcome. Authenticated with the per-claim token in the X-Threa-Callback-Token header. Required scope: `delegations:write` #### Path parameters - `delegationId` (string, required) — Delegation ID (prefixed ULID) #### Request body - `resultMarkdown` (string) — min length 1 · max length 50000 - `metadata` (object) #### Response 200 - `data` (object, required) - `id` (string, required) - `streamId` (string, required) - `title` (string, required) - `status` (string, required) — one of: open, claimed, running, completed, failed, cancelled, expired - `claimedByLabel` (string) - `statusNote` (string) - `resultMessageId` (string) - `sourceConversationId` (string) - `createdAt` (string, required) — date-time - `statusChangedAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /delegations/{delegationId}/complete:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/complete \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "resultMarkdown": "string", "metadata": {} }' ``` *Response 200 · example:* ```json { "data": { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "title": "Auth refactor held pending token-rotation review", "status": "open", "claimedByLabel": "…", "statusNote": "available", "resultMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "sourceConversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "statusChangedAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/delegations/{delegationId}/fail **Fail a delegation** Mark the claimed delegation failed, recording why on its card. Authenticated with the per-claim token in the X-Threa-Callback-Token header. Required scope: `delegations:write` #### Path parameters - `delegationId` (string, required) — Delegation ID (prefixed ULID) #### Request body - `errorMessage` (string, required) — min length 1 · max length 2000 #### Response 200 - `data` (object, required) - `id` (string, required) - `streamId` (string, required) - `title` (string, required) - `status` (string, required) — one of: open, claimed, running, completed, failed, cancelled, expired - `claimedByLabel` (string) - `statusNote` (string) - `resultMessageId` (string) - `sourceConversationId` (string) - `createdAt` (string, required) — date-time - `statusChangedAt` (string, required) — date-time #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /delegations/{delegationId}/fail:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/fail \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "errorMessage": "string" }' ``` *Response 200 · example:* ```json { "data": { "id": "id_01jd2q4z8kxw9v7r3m5t8n", "streamId": "stream_01jd2q4z8kxw9v7r3m5t8n", "title": "Auth refactor held pending token-rotation review", "status": "open", "claimedByLabel": "…", "statusNote": "available", "resultMessageId": "msg_01jd2q4z8kxw9v7r3m5t8n", "sourceConversationId": "conversation_01jd2q4z8kxw9v7r3m5t8n", "createdAt": "2026-03-12T11:42:00.000Z", "statusChangedAt": "2026-03-12T11:42:00.000Z" } } ``` ### POST /api/v1/workspaces/{workspaceId}/delegations/{delegationId}/request-access **Request access to a delegation's stream** For a workspace (bot) key that received the delegation:available nudge but cannot claim it (no channel grant): file an access request that renders as a card in the delegation's stream for a member to approve or deny. Returns already\_granted (no card) when the bot already has access; otherwise the request is idempotent per (bot, stream). 404 for an unknown delegation id — the existence-hiding carve-out is scoped to ids the workspace bot plane already saw on the nudge. A user-scoped key gets 400 (USER\_KEY\_CANNOT\_REQUEST\_ACCESS): a user key's access follows its user, who should join the stream directly. Required scope: `delegations:write` #### Path parameters - `delegationId` (string, required) — Delegation ID (prefixed ULID) #### Request body - `requestedByLabel` (string) — max length 200 #### Response 200 - `data` (object, required) - `requestId` (string) - `status` (string, required) #### Status codes - `200` Successful response - `400` Validation error - `401` Missing or invalid API key - `403` Insufficient permissions or inaccessible resource - `404` Resource not found *POST /delegations/{delegationId}/request-access:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/request-access \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "requestedByLabel": "string" }' ``` *Response 200 · example:* ```json { "data": { "requestId": "request_01jd2q4z8kxw9v7r3m5t8n", "status": "available" } } ``` --- *Source: https://threa.io/developers/markdown* # The message content format. Message bodies are markdown. On top of standard formatting, a few custom link schemes carry what plain markdown has no syntax for: a mention's identity, a file, a channel. They survive a round trip unchanged. ## The content field You send a message body in the `content` field as a markdown string. The API parses it, resolves any references, and stores a structured document. Reads return the canonical markdown in the same `content` field. *send a message:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Deploy is green. Thanks @pierre. Details in #releases." }' ``` What you write and what you read back are not always byte-identical. You can write a mention as a bare `@pierre`; the API resolves it and stores the actor it points at, so the read-back form is the resolved `[@pierre](user:usr_…)`. The text a person sees is the same either way. ## Standard markdown Common GitHub-flavored markdown renders as you'd expect. Supported: | Element | Syntax | | --- | --- | | Bold, italic, strikethrough | `**bold**`, `*italic*`, `~~struck~~` | | Inline code, code blocks | `` `code` ``, triple-backtick fences with a language | | Headings | `# H1` through `###### H6` | | Lists | `- item`, `1. item` | | Tables | GFM pipe tables | | Blockquotes, rules | `> quote`, `---` | | Links | `[text](https://…)` | > **Reserved schemes** — A link whose target starts with one of the schemes below (for example `user:` or `attachment:`) is a typed reference, not a plain link; Threa renders it as a chip or card. Use `https://`, `mailto:`, or a relative path for ordinary links. ## Mentions and channels Write a mention as a bare `@slug` and a channel as `#slug`. The API resolves each one against the workspace and stores a reference to the actor or stream, not the slug. Slugs can change; the stored reference doesn't. *you write:* ```text Ping @pierre and @ariadne about #releases. ``` *you read back:* ```text Ping [@pierre](user:usr_8x2…) and [@ariadne](persona:persona_a1…) about [#releases](channel:stream_4f…). ``` The label inside the brackets is a display name; the link target is the stable id. An actor is a workspace member (`user:`), an agent like Ariadne (`persona:`), or a bot (`bot:`). `@here` and `@channel` are broadcasts and resolve to `broadcast:here` / `broadcast:channel`. If you already know the id, you can write the resolved form directly; it round-trips unchanged. A bare slug that resolves to nothing is left as typed. ## Attachments Upload a file first, then reference the returned id in a message. The scheme is `attachment:`; the link label is the filename shown in the message. *upload, then attach:* ```bash # 1. upload, returns { "id": "attach_…" } curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/attachments \ -H "Authorization: Bearer YOUR_API_KEY" \ -F file=@./report.pdf # 2. reference that id in a message curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Q3 numbers: [report.pdf](attachment:attach_…)" }' ``` ## Emoji Write emoji as `:shortcode:` (for example `:tada:`). Known shortcodes resolve to the character; an unknown one stays as text. Raw unicode emoji you paste in are folded to their shortcode on the way in, so the stored form is consistent. ## Link scheme reference Every typed reference rides on standard `[label](scheme:payload)` link syntax, so a client that doesn't understand a scheme still shows the label as readable text. The ones you'll write are the first group; the rest you'll mostly encounter on reads. | Scheme | Reference | Example | | --- | --- | --- | | `user:` | workspace member mention | `[@pierre](user:usr_…)` | | `persona:` | agent mention (e.g. Ariadne) | `[@ariadne](persona:persona_…)` | | `bot:` | bot mention | `[@deploybot](bot:bot_…)` | | `broadcast:` | `@here` / `@channel` | `[@here](broadcast:here)` | | `channel:` | channel link | `[#releases](channel:stream_…)` | | `attachment:` | uploaded file | `[report.pdf](attachment:attach_…)` | | `memo:` | memory card | `[Auth rewrite](memo:memo_…)` | | `quote:` | quoted reply (read) | `[Alice](quote:stream_…/msg_…/usr_…/user)` | | `shared-message:` | cross-stream share (read) | `[Alice](shared-message:stream_…/msg_…)` | ## Why the schemes exist Threa stores each message as a structured document, not as a string. The markdown you send and read is a projection of that document. Plain markdown has no way to say "this is a mention of member `usr_8x2`," only "this is the text `@pierre`." The schemes carry the missing piece: a markdown link with a stable id, so the projection round-trips through the structured form without losing what a reference points at. For an integration: send plain, readable markdown, and read the resolved form back. If you only need the text, strip the link syntax to its label; a mention reduces to `@pierre`, a file to its filename. --- *Source: https://threa.io/developers/versioning* # API versions. Threa versions the API by date. Each key is pinned to a version when you create it, so your integration keeps seeing the same shapes until you decide to move. When a version introduces a breaking change, your key stays on the older one and you opt in when you are ready. ## How it works A version is a date. The current version is `2026-07-12`. When you create an API key it is pinned to whatever version is current at that moment, and that pin decides which shapes the key sees on every request. You do not have to send anything for this to work. When we later ship a version with a breaking change, existing keys are unaffected. You test the new version by sending the `Threa-Version` header on individual requests, and when your integration is ready you either send that header everywhere or create a new key pinned to the newer version. ## The header Send `Threa-Version` with a version date to override your key's pin for a single request. A valid header takes precedence over the pin. *override the pin for one request:* ```bash curl https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Threa-Version: 2026-07-12" ``` The value must be one of the known versions exactly. An unknown or malformed value returns `400` with the code `INVALID_API_VERSION` and the list of known versions in the error, so a typo fails loudly instead of being treated as the latest. Every response that resolved a version echoes it in a `Threa-Version` response header, so you can always confirm which shapes you received. The `400` for an unknown version has no resolved version, so it carries no echo header. *400 on an unknown version:* ```json { "error": "Unknown API version \"2020-01-01\". Known versions: 2026-07-12", "code": "INVALID_API_VERSION" } ``` ## Reading and changing the pin `GET /api/v1/workspaces/{workspaceId}/me` reports the key's version state alongside its identity, so an agent can discover its pin with a call it already makes: *apiVersion block in the /me response:* ```json { "data": { "kind": "user", "workspaceId": "ws_...", "userId": "usr_...", "apiVersion": { "pinned": "2026-07-12", "resolved": "2026-07-12", "current": "2026-07-12", "supported": ["2026-07-12"] } } } ``` `pinned` is the key's pin, or `null` when the key is unpinned. `resolved` is the version this request used. *detect that a newer version exists:* ```ts interface ApiVersionInfo { pinned: string | null resolved: string current: string supported: string[] } const res = await fetch("https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/me", { headers: { Authorization: "Bearer YOUR_API_KEY" }, }) const { data } = (await res.json()) as { data: { apiVersion: ApiVersionInfo } } if (data.apiVersion.resolved !== data.apiVersion.current) { // A newer version exists. Test against it by sending // "Threa-Version": data.apiVersion.current on individual requests. } ``` Pins belong to the key's owner and are managed in the app, not through the public API: `PATCH` the key in the workspace's key management with `{"apiVersion": ""}` to re-pin it, or `{"apiVersion": null}` to unpin it. An unpinned key always uses the current version, including future breaking changes, so unpin only if you update your integration as versions ship. To adopt newer shapes without touching the pin, send the header or create a new key. ## Breaking vs additive changes Additive changes do not get a new version and can arrive on your current one at any time. Plan for them. These are new endpoints, new optional request fields, new response fields, and new values in response enums that are documented as open sets. Write your client so an unexpected field or enum value is ignored rather than rejected. Breaking changes get a new dated version and never reach a key pinned to an earlier one. A change is breaking when it removes or renames a field, changes a field's type or meaning, tightens request validation, changes a default, or changes an error code or status. This is the same split Stripe uses. | Change | New version? | | --- | --- | | New endpoint | No | | New optional request field | No | | New response field | No | | New value in an open response enum | No | | Removing or renaming a field | Yes | | Changing a field's type or meaning | Yes | | Tightening request validation | Yes | | Changing a default value | Yes | | Changing an error code or status | Yes | ## Support window A version stays supported for at least twelve months after it is succeeded, so a key pinned to an older version keeps working for that long once a newer version ships. ## The path prefix The URL prefix does not change with versions. Every endpoint stays under `/api/v1`, and the `Threa-Version` header carries the version. The `v1` segment is a stable prefix, so paths you hardcode today keep working. Version negotiation happens entirely through the header and the key's pin. ## Changelog Each dated version is listed here with what changed. The current version is `2026-07-12`. Each version has its own OpenAPI spec at `/openapi/.json`, and [/openapi.json](https://threa.io/openapi.json) is the current one. The [reference](https://threa.io/developers/reference.md) renders each version at `/developers/reference/`, with a version picker at the top. ### 2026-07-12 Initial versioned API. --- *Source: https://threa.io/developers/operations* # Running in production. How errors come back, how paging works, how to send a message exactly once, and the rate limits in force. Plus the CORS setting that gates in-browser calls. ## Errors Errors are JSON with a human-readable `error` and, for validation failures, a `details` map of field to reasons: *400 Bad Request:* ```json { "error": "Validation failed", "details": { "query": ["query is required"], "limit": ["Number must be less than or equal to 50"] } } ``` | Status | Means | | --- | --- | | `400` | Request failed schema validation. Check `details`. | | `401` | Missing, malformed, revoked, or expired key. Also if the key owner is no longer active. | | `403` | Authenticated, but not allowed to touch this resource. | | `404` | Resource not found, or your key lacks the scope to see it exists. | | `204` | Success with no body (e.g. a delete). | | `429` | Rate limited. See below. | ## Pagination List endpoints are cursor-paginated. Pass `limit` for page size and `after` to continue. Each response carries `hasMore` and an opaque `cursor`; feed that cursor back as `after` until `hasMore` is false. *first page:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams?limit=50" \ -H "Authorization: Bearer YOUR_API_KEY" ``` *response tail:* ```json { "data": [ /* … */ ], "hasMore": true, "cursor": "eyJ2IjoxLCJzIjoiMjAyNi0wNS0yOC…" } ``` > **Two exceptions** — Listing messages in a stream pages by message `sequence` via `before`/`after` (numeric), with a `hasMore` flag and no opaque cursor. And when you pass a free-text `query` to a list endpoint, results rank by relevance and cursoring is turned off. ## Idempotency Sending a message accepts an optional `clientMessageId` (up to 128 characters). Retrying with the same stream and `clientMessageId` won't create a duplicate. You get the original message back, with the same `201`. Generate one ID per logical message and reuse it across retries. *idempotent send:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "Deploy finished: v2.4.1 is live.", "clientMessageId": "deploy-2.4.1-notify" }' ``` Replace `STREAM_ID` with a real stream (list streams above to find one). The dedupe is a database constraint, so it holds even for concurrent retries. ## Rate limits Several limits stack, each over a rolling 60-second window. A request has to clear all of them, so the tightest one that applies is what you actually feel. These are the current defaults and may be tuned. | Limit | Window | Scope | Applies to | | --- | --- | --- | --- | | **60 requests** | 60s | per API key | every endpoint | | **600 requests** | 60s | per workspace | every endpoint | | **300 requests** | 60s | per client IP | baseline, all routes | | **20 requests** | 60s | per caller | attachment uploads | For a single key on a single host, the **60/min per key** limit is the one you'll hit first. The per-IP baseline matters when many keys share one machine; the per-workspace ceiling caps everyone together. Uploads are held tighter still. Whichever you hit first returns `429`. The body reports the limit and window, and every response carries the standard `RateLimit-*` headers so you can back off before you're cut off: *429 + headers:* ```text HTTP/1.1 429 Too Many Requests RateLimit-Limit: 60 RateLimit-Remaining: 0 RateLimit-Reset: 23 { "error": "Rate limit exceeded", "limit": 60, "windowMs": 60000 } ``` Watch `RateLimit-Remaining` and pause until `RateLimit-Reset` seconds have passed. To go faster, spread work across keys and hosts rather than pushing one key past 60/min. ## CORS > **Why "Run" may fail here** — Calls you run on these pages go straight from your browser to the base URL. A browser only allows that if the API has added this site's origin to its CORS allow-list. Until then, runnable samples report a CORS block. The same request still works from curl or your own server, which aren't subject to it. The allow-list is the `CORS_ALLOWED_ORIGINS` environment variable on the backend (comma-separated origins). Add the docs origin there and the in-browser Run button works. For your own integrations this never applies: a server or CLI client has no preflight to satisfy. --- *Source: https://threa.io/developers/cli* # The threa command line. `threa` wraps the public API as a command-line tool: search messages and memos, read streams, post, and run the delegation lifecycle from a shell. The same binary serves those operations over the Model Context Protocol with `threa mcp serve`, so agent hosts like Claude Desktop can connect too. ## Install The CLI lives in the open-source repo at [github.com/threahq/threa](https://github.com/threahq/threa) under `packages/cli`. It runs on [Bun](https://bun.sh) and is not yet published to a package registry, so you install it from a checkout: *install from a checkout:* ```bash git clone https://github.com/threahq/threa.git cd threa && bun install cd packages/cli && bun link # puts a global `threa` on your PATH ``` Without `bun link` you can run it in place with `bun packages/cli/src/cli.ts`. After pulling a newer checkout the linked binary picks up the changes automatically. ## Configure The tool binds one workspace with one API key. Create the key in **Settings → API keys** (see [Authentication](https://threa.io/developers/authentication.md)), then set environment variables or write a config file. Environment variables win when both exist. *environment variables:* ```bash export THREA_API_KEY=threa_uk_... export THREA_WORKSPACE_ID=YOUR_WORKSPACE_ID threa whoami ``` *~/.threa/config.json:* ```json { "apiKey": "threa_uk_...", "workspaceId": "YOUR_WORKSPACE_ID", "output": "text" } ``` A user key acts as you and shows a via-API badge on what it posts; a workspace key acts as its bot. The key's scopes decide which commands work. Start with `threa whoami` to confirm the binding. ## Commands Commands group by resource, verb second. `threa --help` lists everything; each command documents its own flags with `--help`. *the surface:* ```bash threa whoami threa search "release plan" --what messages|memos|attachments threa streams list --type channel threa streams read "#general" --members threa conversations list --stream "#general" threa conversations read conv_... threa messages send "#general" "Hello from the CLI" threa messages edit msg_... "Edited" threa messages delete msg_... threa messages find-by-metadata source=ci threa memos get memo_... threa attachments get att_... --url threa labels list threa labels add triage "#general" threa users list threa delegations list threa delegations claim dlg_... --label "My machine" threa delegations update dlg_... --note "tests passing" threa delegations finish dlg_... --outcome complete --result - < report.md threa mcp serve ``` Two commands worth calling out. `search` is the one retrieval entry point: `--what memos` searches the memory Threa builds from your conversations, and an empty memo query browses the most recent. `messages send` can open a conversation with `--new-conversation` and continue one with `--conversation conv_...`, which is how an external agent keeps a thread of work going across runs. ## Referring to things Anywhere a command takes a stream you can pass the raw `stream_...` id or a `#channel-slug`. User slugs work as `@user-slug`. Lookups are exact and cached; a miss or an ambiguous match fails with an error that names the candidates and how to find the id. ## Output and exit codes The CLI prints short human-readable tables by default, piped or not. `-o json` (or `--json`) switches to JSON and works anywhere in the command line, so `threa --json streams list | jq` and `threa streams list -o json` both behave the way you expect. An `"output": "json"` entry in the config file sets the default; an explicit flag wins. Errors go to stderr as a single JSON object with a `code`, a `message`, and often a `hint`. Exit codes: 0 for success, 1 for an API or reference error, 2 for a usage mistake. ## The MCP server `threa mcp serve` exposes the same operations as 22 typed MCP tools over stdio, for hosts that speak MCP rather than a shell. Register it with Claude Code: *register with Claude Code:* ```bash claude mcp add threa \ --env THREA_API_KEY=threa_uk_... \ --env THREA_WORKSPACE_ID=YOUR_WORKSPACE_ID \ -- bun /path/to/threa/packages/cli/src/cli.ts mcp serve ``` Other MCP hosts use their own registration format with the same command and environment. The tool names mirror the CLI surface: `search`, `read_stream`, `send_message`, `claim_delegation`, and so on. ## Using it from agents For a coding agent with shell access the CLI is usually the better integration: no per-session registration, output that pipes into other tools, and no tool schemas taking up context. The repo ships an agent skill teaching effective use; `threa skill install` copies it to `~/.claude/skills/threa-cli/` for Claude Code. The delegation commands close the loop described in [Recipes](https://threa.io/developers/recipes.md): a Threa persona hands work to your machine, your agent claims it, posts progress, and the finished result lands back in the stream where Threa's memory keeps it. Claim tokens persist in `~/.threa/state.json`, so a crashed run can pick its claim back up. --- *Source: https://threa.io/developers/recipes* # Worked examples. Each one is a real task. Set your credentials in the panel (top right) and the runnable samples work as you read. The last wires a local agent into your workspace, the way the Pi extension does. ## Find out what was decided, and why Memos are Threa's record of decisions and context pulled from conversations. Search them by topic (semantic by default) and you get back titles, abstracts, tags, and links to the source messages. *search memos:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/memos/search \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "why did we pause the auth refactor", "limit": 5 }' ``` Take a memo's `id` from the results and pull its full provenance: the source stream and the exact messages, with authors and timestamps. *memo with sources:* ```bash curl https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/memos/MEMO_ID \ -H "Authorization: Bearer YOUR_API_KEY" ``` ## Notify a stream from CI A build or deploy script can post into a channel. Give the message a stable `clientMessageId` so a retried pipeline step never double-posts. First find a stream to post into: *list channels:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams?type=channel&limit=20" \ -H "Authorization: Bearer YOUR_API_KEY" ``` Then post, reusing the same ID across retries: *post once:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/streams/STREAM_ID/messages \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "content": "**Deploy:** v2.4.1 shipped to production. :rocket:", "clientMessageId": "ci-deploy-2.4.1" }' ``` ## Mirror a search into your own tool Message search takes `semantic` and `exact` toggles and filters by stream, type, author, and date window. Drop it behind a slash command, an editor extension, or a dashboard. *search messages:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/messages/search \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "query": "rotation flow", "semantic": true, "type": ["channel", "thread"], "limit": 10 }' ``` ## Connect your local agent Threa runs a **bot runtime** protocol: your agent registers a presence, then claims work whenever someone `@mentions` its bot in a stream, does the work locally, and posts the reply back. It's pull-based, so your agent can live on your laptop or a Pi behind a NAT with no inbound ports. The Pi remote extension implements this in full. ### 1 · Create a bot and a bot key In the app, create a bot (give it the `mentionable` trait so people can summon it), then mint a `threa_bk_` key on it with `bot-runtime:write`, `bot-invocations:write`, and `messages:write`. Use that key as the credential below. ### 2 · Heartbeat a presence Tell Threa your runtime is alive and accepting work. Repeat every 15–30s. The `instanceId` is any stable per-process string; `runtimeKind` is one of `pi-local`, `hermes`, `openclaw`, `claude-code-channel`, or `custom`. *heartbeat:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/bot-runtime/presence \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "runtimeKind": "custom", "instanceId": "my-laptop-1", "status": "available", "acceptingInvocations": true, "capabilities": { "supportsActiveScratchpad": false } }' ``` ### 3 · Claim, work, reply Poll for a pending invocation. When you get one, it carries the prompt and a `claimToken`; do your work, then complete it with a reply (or renew the claim first if you need longer than the TTL). A full loop in TypeScript: *agent-loop.ts:* ```ts const BASE = "https://app.threa.io" const WS = "YOUR_WORKSPACE_ID" const KEY = "YOUR_API_KEY" // a threa_bk_ bot key const INSTANCE = "my-laptop-1" const auth = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" } const api = (path: string, body: unknown) => fetch(`${BASE}/api/v1/workspaces/${WS}${path}`, { method: "POST", headers: auth, body: JSON.stringify(body), }).then((r) => r.json()) async function loop() { // Keep presence fresh in the background. setInterval( () => api("/bot-runtime/presence", { runtimeKind: "custom", instanceId: INSTANCE, status: "available", acceptingInvocations: true, capabilities: {}, }), 20000 ) while (true) { const { data: inv } = await api("/bot-invocations/claim", { runtimeKind: "custom", instanceId: INSTANCE, supportedCapabilities: ["mentionable"], claimTtlSeconds: 120, }) if (!inv) { await new Promise((r) => setTimeout(r, 3000)) continue } // inv.promptMarkdown is what the user said. Do real work here. const reply = await runYourAgent(inv.promptMarkdown) await api(`/bot-invocations/${inv.id}/complete`, { instanceId: INSTANCE, claimToken: inv.claimToken, finalMessageMarkdown: reply, }) } } ``` > **Faster than polling** — There's also a Socket.IO `/bot` namespace that pushes `bot_invocation:available` events so you can react instantly and keep a poll only as a backstop. The Pi extension uses the socket with a 30s poll fallback. Read `extensions/pi-remote/` in the repo for a complete implementation, including session-control commands like `/model`, `/thinking`, and `/compact` driven from a scratchpad. A bot key with `streams:read` and `memos:read` can also read history and search the same memory Ariadne uses while it works, so your local agent answers with the full workspace context behind the message that summoned it. ## Run a delegation When Threa's assistant is asked for work that needs a real machine (fix a bug in a repo, run a migration), it doesn't pretend to do it in chat. It compiles a self-contained brief and posts a **delegation card** in the stream. The card waits, status `open`, until an agent claims it through this API. Every transition after that shows on the card live: claimed, running with progress notes, then completed with the result posted back into the conversation, where Threa's memory learns the outcome. Works with either key kind: a personal `threa_uk_` key posts the result as you (with a via-API badge); a workspace bot key posts it as the bot, the shared-runner setup. Both need the `delegations:read` and `delegations:write` scopes. ### 1 · Find open work The queue is filtered to streams your key can access. *list open delegations:* ```bash curl "https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations" \ -H "Authorization: Bearer YOUR_API_KEY" ``` ### 2 · Claim it The claim is atomic: exactly one claimer wins; a lost race returns `409 DELEGATION_NOT_OPEN`. The response carries the full brief, the resolved context refs, and a `claimToken` on a 15-minute lease. The token is shown once and cannot be retrieved again. *claim:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/claim \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "claimedByLabel": "build-box / my agent" }' ``` ### 3 · Report progress, keep the lease alive Send the token as an `X-Threa-Callback-Token` header on every later call. `status` puts a note on the card and renews the lease; `heartbeat` renews it silently. Let the lease lapse and the card flips to `expired` so nobody waits on a dead runner. *progress note:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/status \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Threa-Callback-Token: CLAIM_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "statusNote": "Tests passing, writing the migration" }' ``` ### 4 · Complete with the result Completion posts a compact anchor into the stream with the full result as a thread reply under it, and the card flips in the same transaction. Optional `metadata` is stamped on the result message (queryable later with `find-by-metadata`). Retries are safe: a retried complete bearing the same token returns the committed outcome instead of double-posting. *complete:* ```bash curl -X POST https://app.threa.io/api/v1/workspaces/YOUR_WORKSPACE_ID/delegations/DELEGATION_ID/complete \ -H "Authorization: Bearer YOUR_API_KEY" \ -H "X-Threa-Callback-Token: CLAIM_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "resultMarkdown": "Shipped in PR #42. All acceptance criteria pass.", "metadata": { "github.pr": "https://github.com/acme/repo/pull/42" } }' ``` Something went wrong instead? `POST …/fail` with an `errorMessage` puts the reason on the card. And the card's own *Copy prompt* button embeds these instructions with the real ids filled in, so an agent that receives the pasted brief knows how to claim it. ## Connect an encrypted agent An end-to-end-encrypted scratchpad stores only ciphertext: the server never sees the prompt, the reply, or a single trace step. Your agent holds the key, so it can send the exact command, the full diff, and real output. Same claim-and-reply loop as above, with a sealing layer on top. Three things to get right: a per-machine identity key, the AAD that binds each ciphertext, and the two-phase handshake that mints the stream key. > **The format** — Suite is RFC 9180 `DHKEM(X25519, HKDF-SHA256)` + `HKDF-SHA256` + `AES-256-GCM`. A stream owns one symmetric key (SSK) per *generation*; the owner HPKE-wraps it to each recipient's public key. Every ciphertext is AES-256-GCM under the SSK, with additional-authenticated-data that pins it to its slot. Get these byte-exact or GCM verification fails: > wrap: `streamId|keyGeneration|recipientKeyId`  ·  message & step: `streamId|messageId|senderId` > An envelope is `{ v: 2, keyGeneration, iv, aad }` (iv/aad base64); the ciphertext travels beside it. A ~40-line helper covers the crypto. The X25519 KEM is pure-JS so it runs on any runtime (some, like Bun, lack native X25519): *sealed.ts:* ```ts import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core" import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519" const suite = new CipherSuite({ kem: new DhkemX25519HkdfSha256(), kdf: new HkdfSha256(), aead: new Aes256Gcm(), }) const utf8 = (s: string) => new TextEncoder().encode(s) const b64 = (u: Uint8Array) => btoa(String.fromCharCode(...u)) const unb64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) const wrapAad = (stream: string, gen: number, keyId: string) => utf8(`${stream}|${gen}|${keyId}`) const msgAad = (stream: string, id: string, sender: string) => utf8(`${stream}|${id}|${sender}`) // Your identity key (BIK): one X25519 keypair per machine, persisted 0600. // NOT derived from the API key; a leaked key must not expose past scratchpads. export async function makeBik() { const kp = await suite.kem.generateKeyPair() return { publicKeyId: `bik_${crypto.randomUUID()}`, publicKey: b64(new Uint8Array(await suite.kem.serializePublicKey(kp.publicKey))), privateKey: kp.privateKey, } } const importPub = async (pub: string) => suite.kem.deserializePublicKey(unb64(pub).buffer) // Owner (or you, at create) wraps an SSK to a recipient's public key. export async function wrapSSK(ssk: Uint8Array, pub: string, aad: Uint8Array) { const s = await suite.seal({ recipientPublicKey: await importPub(pub) }, ssk, aad) return { wrapEnc: b64(new Uint8Array(s.enc)), wrapCt: b64(new Uint8Array(s.ct)) } } type KeyWrap = { wrapEnc: string; wrapCt: string } export const unwrapSSK = async (wrap: KeyWrap, priv: CryptoKey, aad: Uint8Array) => new Uint8Array(await suite.open({ recipientKey: priv, enc: unb64(wrap.wrapEnc) }, unb64(wrap.wrapCt), aad)) // Open / seal an SSK-encrypted message or step. async function aesKey(ssk: Uint8Array, use: "encrypt" | "decrypt") { return crypto.subtle.importKey("raw", ssk, "AES-GCM", false, [use]) } type Envelope = { iv: string; aad: string } export async function open(ssk: Uint8Array, env: Envelope, ct: string) { const key = await aesKey(ssk, "decrypt") const pt = await crypto.subtle.decrypt( { name: "AES-GCM", iv: unb64(env.iv), additionalData: unb64(env.aad) }, key, unb64(ct) ) return new TextDecoder().decode(pt) } export async function seal(ssk: Uint8Array, stream: string, id: string, sender: string, gen: number, text: string) { const iv = crypto.getRandomValues(new Uint8Array(12)) const aad = msgAad(stream, id, sender) const key = await aesKey(ssk, "encrypt") const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, key, utf8(text))) return { ciphertext: b64(ct), envelope: { v: 2, keyGeneration: gen, iv: b64(iv), aad: b64(aad) } } } ``` ### 1 · Register your identity key Generate the BIK once, persist it, and send its public half on every presence write. The server overwrites the stored key each time, so omitting it un-registers you. *presence with a key:* ```ts const bik = await loadOrMakeBik() // makeBik(), then reuse across restarts await api("/bot-runtime/presence", { runtimeKind: "custom", instanceId: INSTANCE, status: "available", acceptingInvocations: true, publicKey: bik.publicKey, publicKeyId: bik.publicKeyId, }) ``` ### 2 · Create an encrypted scratchpad Two-phase, because the wrap AAD binds to the stream id the server mints. Ask for the owner's public key, create the session encrypted, then wrap a fresh generation-0 SSK to the owner and to yourself: *two-phase create:* ```ts // Owner's key. A 404 means they haven't set up encryption yet; tell the user. const { data: owner } = await get("/bot-runtime/owner-e2e-key") // { keyId, publicKey } const { data: link } = await api("/bot-runtime/sessions", { runtimeKind: "custom", instanceId: INSTANCE, runtimeSessionId: SESSION, displayName: "My sealed agent", e2e: { ownerKeyId: owner.keyId }, }) // link.e2eEnabled === true, link.rootStreamId is the stream that owns the key. const ssk = crypto.getRandomValues(new Uint8Array(32)) const wraps = [ { recipientKind: "user", recipientKeyId: owner.keyId, ...(await wrapSSK(ssk, owner.publicKey, wrapAad(link.rootStreamId, 0, owner.keyId))), }, { recipientKind: "bot", recipientKeyId: bik.publicKeyId, ...(await wrapSSK(ssk, bik.publicKey, wrapAad(link.rootStreamId, 0, bik.publicKeyId))), }, ] await api(`/streams/${link.rootStreamId}/e2e/key-wraps`, { keyGeneration: 0, wraps }) ``` ### 3 · Claim, then open with your key Claim as usual. On an encrypted stream the response carries a `sealedContext` instead of a plaintext prompt: the SSK wraps addressed to your BIK, the sealed trigger, and a per-turn `callbackToken` that authorizes your sealed writes. *open a sealed claim:* ```ts const { data: inv } = await api("/bot-invocations/claim", { runtimeKind: "custom", instanceId: INSTANCE, supportedCapabilities: ["active-scratchpad"], claimTtlSeconds: 120, }) const sc = inv.sealedContext const stream = inv.rootStreamId // Recover the SSK for each generation the owner wrapped to you. const ssks = new Map() for (const w of sc.wraps) ssks.set(w.keyGeneration, await unwrapSSK(w, bik.privateKey, wrapAad(stream, w.keyGeneration, bik.publicKeyId))) const promptSSK = ssks.get(sc.prompt.envelope.keyGeneration)! const prompt = await open(promptSSK, sc.prompt.envelope, sc.prompt.ciphertext) // prompt is the real user message. Run your agent on it. ``` ### 4 · Seal every reply and step Seal under the reply generation's SSK, bound to a fresh id, and post to the sealed endpoints with the callback token in the `X-Threa-Callback-Token` header. Steps carry a `step_` id, the reply a `msg_` id. *seal back:* ```ts const gen = sc.reply.keyGeneration const sender = sc.reply.senderId const ssk = ssks.get(gen)! const sealed = (id: string, text: string) => seal(ssk, stream, id, sender, gen, text) const cb = { "X-Threa-Callback-Token": sc.callbackToken, "Content-Type": "application/json" } const post = (path: string, body: unknown) => fetch(`${BASE}/api/v1/workspaces/${WS}${path}`, { method: "POST", headers: cb, body: JSON.stringify(body) }) // A trace step: the full command, the real output. The server can't read it. const stepId = `step_${crypto.randomUUID()}` await post(`/bot-invocations/${inv.id}/sealed-steps`, { stepId, stepType: "tool_call", ...(await sealed(stepId, `$ rm -rf ./build\n${output}`)), }) // The final reply, then the turn is done. const msgId = `msg_${crypto.randomUUID()}` await post(`/bot-invocations/${inv.id}/sealed-complete`, { reply: { messageId: msgId, ...(await sealed(msgId, replyMarkdown)) }, }) ``` > **Steps over the socket** — High-volume steps go over the Socket.IO `/bot` namespace as `bot:invocation:sealed-steps` (`{ invocationId, callbackToken, steps: [frame] }`), so a chatty turn never bills the edge worker. Mid-turn progress messages seal to `/bot-invocations/:id/sealed-messages` the same way. The full request schemas are in the [reference](https://threa.io/developers/reference.md); the Pi (`extensions/pi-remote/`) and Claude Code (`extensions/claude-code-remote/`) harnesses are complete implementations. Re-keying is the owner's: inviting or removing a recipient rolls the generation and re-wraps the new SSK to the current set, so a removed agent keeps old history but reads nothing new. Your key never leaves your machine, and nothing you send is ever stored in the clear. --- *Source: https://threa.io/developers/feature-requests* # Something missing? The API is small on purpose and grows in the open. If there's an endpoint, scope, or integration you need, file it. Threa is public source, so requests go to the issue tracker. The form below opens a prefilled issue on the public repo for you to review and submit. You can also browse what's already been requested before adding yours. One-line summary Area Public API Bot runtime / agents Memos & search Auth & keys MCP server Other What are you trying to do? Prefer to look first? [Browse open issues](https://github.com/threahq/threa/issues) on `threahq/threa`. > **Already requested** — A bulk export endpoint, an MCP server, and outbound webhooks come up most often. A thumbs-up on an existing issue helps us order the queue.