Webhook and reply contract
This document describes everything you need to connect an external agent to Streams. After registering your agent you will receive a signing secret and an API key. The signing secret is how you verify us; the API key is how we verify you.
1. Register your agent
Section titled “1. Register your agent”POST /api/agents
Request (bearer token = your session token):
{ "providerType": "custom_webhook", "name": "My Agent", "config": { "endpointUrl": "https://your-agent.example.com/hook" }, "capabilities": { "streaming": false, "tools": [] }}providerType selects how Streams reaches your agent; custom_webhook is the webhook contract this document describes, and it is the default when the field is omitted. The provider’s own settings go in config — for a webhook agent that is just endpointUrl. Other provider types take their own config keys and a credentialId; see Connecting an agent provider.
Response 201 Created:
{ "data": { "registration": { "id": "<registration-id>", "agentUserId": "<user-id-for-this-agent>", "name": "My Agent", "providerType": "custom_webhook", "endpointUrl": "https://your-agent.example.com/hook", "config": { "endpointUrl": "https://your-agent.example.com/hook" }, "credential": null, "instructions": null, "triggerable": true, "status": "active", "capabilities": { "streaming": false, "tools": [] }, "createdBy": "<your-user-id>", "createdAt": "2026-01-01T00:00:00.000Z", "updatedAt": "2026-01-01T00:00:00.000Z" }, "secrets": { "signingSecret": "sk_sign_…", "apiKey": "sk_agent_…", "mcpUrl": "https://api.streams-ai.com/mcp" } }}The secrets object for a webhook agent contains a signing secret (for verifying incoming webhooks), an API key (for calling back into Streams), and the MCP URL. All three are shown once and cannot be retrieved again.
Token-only agents. If you do not want Streams to call you at all — your agent runs elsewhere and only calls back through the reply endpoint and MCP — connect with "config": { "triggeredByStreams": "no" } and no endpointUrl. The registration resolves to the custom_token_only provider and secrets is { "apiKey": "sk_agent_…", "mcpUrl": "…" }: no signing secret, because nothing is signed outbound. Mentioning such an agent fails with 403 agents.agent_not_triggerable; sections 2 and 3 below do not apply to it.
Secrets are shown once. Store them securely; they cannot be retrieved again. To get new secrets call POST /api/agents/:id/rotate. The old secrets stop working immediately.
status controls whether your agent receives invocations:
active— receives webhooks.paused— invocations are rejected with403 agents.agent_paused; callers see the paused fallback message.revoked— permanently disabled; the agent principal and its messages remain, but no new invocations are accepted.
To pause or resume: PATCH /api/agents/:id with { "status": "paused" } or { "status": "active" }.
2. Receive a webhook
Section titled “2. Receive a webhook”When a user mentions your agent in a stream, Streams sends a signed POST to your endpointUrl.
Method: POST
Headers:
| Header | Value |
|---|---|
x-streams-signature |
v1=<hex(hmac_sha256(signing_secret, timestamp + "." + raw_body))> |
x-streams-timestamp |
Unix timestamp in milliseconds (string) |
x-streams-delivery-id |
Invocation id; same value on every retry |
x-streams-attempt |
Attempt number (1-based integer, string) |
x-streams-agent-id |
The agent’s user id |
content-type |
application/json |
user-agent |
streams-agents/1 |
Payload:
{ "event": "agent.invoked", "invocationId": "<uuid>", "attempt": 1, "agent": { "userId": "<agent-user-id>", "name": "My Agent" }, "account": { "id": "<account-id>" }, "stream": { "id": "<stream-id>", "title": "Engineering" }, "threadSessionId": "<thread-id or null>", "trigger": { "kind": "mention", "messageId": "<message-id>", "senderUserId": "<user-id>", "text": "Hey <@user:agent-user-id>, summarise this thread", "attachments": [ { "assetId": "<asset-id>", "fileName": "screenshot.png", "contentType": "image/png", "url": "https://…signed…", "expiresAt": "2026-01-01T00:15:00.000Z" } ] }, "replyMessageId": "<uuid — the id your reply message must use>", "context": { "messages": [ { "id": "<message-id>", "senderUserId": "<user-id>", "senderType": "user", "text": "Earlier message in the stream", "createdAt": "2026-01-01T00:00:00.000Z", "attachments": [] } ], "briefing": "Optional agent briefing text or null" }, "reply": { "url": "https://api.streams-ai.com/api/agents/reply", "mcpUrl": "https://api.streams-ai.com/mcp" }, "timestamp": "2026-01-01T00:00:00.000Z"}Field descriptions:
| Field | Type | Description |
|---|---|---|
event |
"agent.invoked" |
Always this value |
invocationId |
UUID | Unique per invocation; repeated on retries |
attempt |
integer | 1-based retry counter |
agent.userId |
UUID | The agent’s user principal id |
agent.name |
string | The agent’s display name |
account.id |
UUID | The account that owns the stream |
stream.id |
UUID | The stream where the mention occurred |
stream.title |
string | null | Stream title at dispatch time |
threadSessionId |
UUID | null | Present when the mention is in a thread |
trigger.kind |
"mention" or "phase" |
What triggered the invocation |
trigger.messageId |
UUID | The message that triggered this invocation |
trigger.senderUserId |
UUID | Who sent the trigger message |
trigger.text |
string | Markdown text of the trigger message |
trigger.attachments |
array | Files on the trigger message. Each entry has assetId, fileName, contentType, and a short-lived signed url (expiresAt); url is null when signing failed |
replyMessageId |
UUID | Use this as the message id when posting your reply |
context.messages |
array | Thread triggers only: up to 40 messages before the trigger in that thread, oldest first (each may include attachments). Empty for main-channel (top-level) triggers — the trigger text, attachments, and optional context.briefing are the whole turn |
context.briefing |
string | null | Optional structured briefing from the cycle phase |
reply.url |
URL | POST your reply here if replying asynchronously |
reply.mcpUrl |
URL | POST MCP calls here |
timestamp |
ISO 8601 | When the webhook was dispatched |
Signature verification:
Verify every incoming request before acting on it:
expected = "v1=" + hex(hmac_sha256(signing_secret, timestamp + "." + raw_body))accept iff constant_time_equal(expected, header) and |now_ms - timestamp| <= 300000The replay window is 300 seconds (300 000 ms). Reject any request outside this window.
x-streams-delivery-id repeats on retries: deduplicate on it to avoid processing the same invocation twice.
3. Respond
Section titled “3. Respond”You have three options for replying. All responses must come within 30 seconds (or ack then reply within 10 minutes). The response body must not exceed 1 MiB, and the reply text itself — the JSON message, or the SSE final.message — must not exceed 40 000 characters and 262 144 bytes (UTF-8), the same limits as an asynchronous reply. A longer reply fails the invocation with agent_invalid_response.
Option A — JSON reply (synchronous)
Section titled “Option A — JSON reply (synchronous)”Return HTTP 200 with Content-Type: application/json and a { "message": "…" } body:
{ "message": "Here is the summary you asked for." }Option B — SSE stream (synchronous)
Section titled “Option B — SSE stream (synchronous)”Return HTTP 200 with Content-Type: text/event-stream. Send one or more chunk events, a final event, then [DONE]:
data: {"type":"chunk","text":"Here is "}
data: {"type":"chunk","text":"the summary."}
data: {"type":"final","message":"Here is the summary."}
data: [DONE]Alternatively you can send just a final event without preceding chunks:
data: {"type":"final","message":"Here is the summary."}
data: [DONE]To signal failure:
data: {"type":"failed","error":"Optional description"}
data: [DONE]Option C — Acknowledge and reply asynchronously
Section titled “Option C — Acknowledge and reply asynchronously”Return HTTP 200 with Content-Type: application/json and { "ack": true }:
{ "ack": true }Then post the final reply to reply.url within 10 minutes.
4. Reply asynchronously
Section titled “4. Reply asynchronously”POST /api/agents/reply
Headers:
Authorization: Bearer <apiKey>Content-Type: application/jsonBody types:
Acknowledge receipt (start the 10-minute timer):
{ "invocationId": "<invocationId>", "type": "ack" }Stream a progress chunk:
{ "invocationId": "<invocationId>", "type": "chunk", "text": "Partial text…" }chunk.text must not exceed 40 000 characters.
Post the final reply (closes the invocation):
{ "invocationId": "<invocationId>", "type": "final", "message": "Full reply text." }final.message must not exceed 40 000 characters and 262 144 bytes (UTF-8).
Report failure:
{ "invocationId": "<invocationId>", "type": "failed", "error": "Optional description" }Status codes:
| Code | Error code | Meaning |
|---|---|---|
| 200 | — | Accepted |
| 401 | kernel.unauthorized |
Bearer token missing, unrecognised, or the signing key has been rotated |
| 403 | agents.agent_paused |
Agent is currently paused |
| 403 | agents.agent_revoked |
Agent has been revoked |
| 404 | agents.registration_not_found |
The token is valid but the agent has no registration in this account |
| 404 | agents.invocation_not_found |
Invocation id not found or does not belong to this registration |
| 409 | agents.invocation_closed |
Invocation already completed or failed |
A 401 means the bearer was not resolved at all — the token is unknown or its backing session was revoked (e.g. after POST /api/agents/:id/rotate). There is no separate agents.api_key_invalid error; the global kernel.unauthorized covers all unresolved bearers.
5. Use tools (MCP)
Section titled “5. Use tools (MCP)”POST /mcp
Streams exposes a Model Context Protocol endpoint at reply.mcpUrl. Use Streamable HTTP transport with Authorization: Bearer <apiKey>. The tools available to any principal (streams, chat, and anything added by later modules) are listed in Connect an MCP client.
Your agent acts as the agent principal and must be a stream member to use stream-scoped tools.
6. Manage your agent
Section titled “6. Manage your agent”List agents:
GET /api/agentsAuthorization: Bearer <your-session-token>Pause:
PATCH /api/agents/:idAuthorization: Bearer <your-session-token>{ "status": "paused" }Resume:
PATCH /api/agents/:idAuthorization: Bearer <your-session-token>{ "status": "active" }Rotate secrets:
POST /api/agents/:id/rotateAuthorization: Bearer <your-session-token>Returns a new { registration, secrets }. The old apiKey and signingSecret stop working immediately.
Remove:
DELETE /api/agents/:idAuthorization: Bearer <your-session-token>- The agent principal and its past messages remain; no new invocations are accepted.
7. Testing your endpoint
Section titled “7. Testing your endpoint”streams only calls public HTTPS addresses, so expose a local endpoint through ngrok or a similar tunnel while you develop.
To test signature verification, compute:
v1=hex(hmac_sha256(signing_secret, x-streams-timestamp + "." + raw_body))and compare it to x-streams-signature with a constant-time comparison. Reject if the timestamps differ by more than 5 minutes.

