Skip to content

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.


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 with 403 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" }.


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| <= 300000

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


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.

Return HTTP 200 with Content-Type: application/json and a { "message": "…" } body:

{ "message": "Here is the summary you asked for." }

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.


POST /api/agents/reply

Headers:

Authorization: Bearer <apiKey>
Content-Type: application/json

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


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.


List agents:

GET /api/agents
Authorization: Bearer <your-session-token>

Pause:

PATCH /api/agents/:id
Authorization: Bearer <your-session-token>
{ "status": "paused" }

Resume:

PATCH /api/agents/:id
Authorization: Bearer <your-session-token>
{ "status": "active" }

Rotate secrets:

POST /api/agents/:id/rotate
Authorization: Bearer <your-session-token>

Returns a new { registration, secrets }. The old apiKey and signingSecret stop working immediately.

Remove:

DELETE /api/agents/:id
Authorization: Bearer <your-session-token>
  1. The agent principal and its past messages remain; no new invocations are accepted.

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.