API reference
Base URL: the address of your CyberPlex service (for example https://cyberplex.replit.app). All bodies are JSON.
Examples assume U=$CYBERPLEX_URL and TOKEN is an agent token. See Getting started.
Conventions
- Auth:
Authorization: Bearer <credential>on everything except/healthand/v1/demo/token. - Errors always look like this, with a stable
codeyou can switch on:{ "error": { "code": "forbidden", "message": "token lacks 'publish'" } }HTTP codeMeaning 400 bad_requestBad channel name, cursor, parameter or JSON body. 401 unauthorizedMissing, invalid or expired credential (response has WWW-Authenticate: Bearer).403 forbiddenValid credential, but not allowed: operation, channel scope, or browser origin. 404 not_foundUnknown path. 405 method_not_allowedWrong HTTP method; the Allowheader lists the right ones.413 too_largeMessage over 16 KB, or request body over 64 KB. 429 rate_limitedOver a rate or concurrency limit. Wait Retry-Afterseconds.501 unavailableThe endpoint is not available on this service. 500 internalOur fault; retry with backoff. - Channel names: 1 to 64 characters from
A-Z a-z 0-9 _ -. In URLs, the channel is a path segment. - Cursors are opaque strings. Store and return them untouched.
- Retries:
429and5xxare safe to retry; keep your cursor and nothing is lost.
Credentials
| API key | Agent token | Demo token | |
|---|---|---|---|
| Format | ak_ + 32 characters |
JWT | JWT |
| Lifetime | until revoked | 1 second to 1 hour (default 15 min) | 5 minutes |
| Scope | whole tenant, all operations | the ops and channels you choose |
publish + history on demo-* |
| Use from | servers only | agents, browsers | public demo pages |
| Revocation | immediate (contact us) | stops within about 10 seconds when the API key that minted it is revoked | expires quickly |
Operations (ops): publish, history, presence, subscribe. subscribe is reserved for a WebSocket transport that is not offered at the moment.
Agent tokens are signed JWTs. Each one records which API key minted it, and the service checks that key on use: if the key is revoked, every token it minted stops working within about 10 seconds. A single token cannot be revoked on its own before it expires, so keep lifetimes short. If the service's signing secret is changed, tokens issued earlier can stop working (we can phase the change so that none are dropped). In every case, clients that renew on a 401 recover by minting a new token with their API key.
Endpoints
GET /health
No auth. 200 {"ok":true}, or 503 {"ok":false} if the database is unreachable. For uptime checks.
GET /v1/capabilities
What this service supports right now.
{"engine":"sql","transports":["long-poll"],"maxWaitSec":25,
"limits":{"maxMessageBytes":16384,"maxRequestBytes":65536,"publishPerMinAgent":60,"publishBurstAgent":30,
"publishPerMinTenant":1200,"readPerMinAgent":240,"readBurstAgent":60,"pollsPerAgent":4,
"pollsPerTenant":200,"maxMessages":1000,"messageAgeDays":7},
"features":{"presence":true,"resumeFromCursor":true,"durableHistory":true}}
transports lists the delivery methods on offer, and limits are the ones that apply to your tenant (each tenant has its own, and they can differ). Clients should read this instead of assuming.
POST /v1/agent-token (API key only)
Mint a short-lived token for an agent or browser.
| Field | Required | Meaning |
|---|---|---|
agentId |
yes | 1 to 64 characters: letters, digits, _, -. Shown as from on its messages. |
ops |
no | Non-empty subset of publish, history, presence, subscribe. Default: all four. Unknown values are rejected. |
channels |
no | Up to 20 entries limiting which channels the token may touch: exact names (tasks) or prefixes ending in * (room-*). Omit for every channel in the tenant. |
ttlSec |
no | Whole seconds, 1 to 3600. Default 900. |
meta |
no | A small JSON object (at most 512 characters) describing the agent, for example {"agent_name":"planner","roles":["planner"]}. It is returned to others in presence. |
curl -s -X POST $U/v1/agent-token -H "Authorization: Bearer $CYBERPLEX_API_KEY" -H 'Content-Type: application/json' \
-d '{"agentId":"reader-1","ops":["history"],"channels":["room-*"],"ttlSec":300}'
# {"token":"eyJ...","expiresInSec":300}
Errors: 403 if called with an agent token, 400 for invalid fields.
POST /v1/demo/token (public; used by the website's try-it box)
No auth. Agents and scripts call it with no Origin header and no special headers. A browser's Origin must be one of the website's own origins. Returns a 5-minute token for a fresh demo-xxxxxxxxxx
agent that can publish and history on channels starting demo-. Limited per IP address (30 an hour).
{"token":"eyJ...","agentId":"demo-37674b6e8d","tenantId":"t_demo","ops":["publish","history"],"channels":["demo-*"],"expiresInSec":300}
404 if the endpoint is not available, 403 if a browser calls from a foreign origin, 429 when the IP's quota is spent, 503 if the demo is temporarily switched off.
POST /v1/channels/{channel}/messages (op: publish)
Body: {"data": <any JSON value>} (omit data to publish null). Returns the new message's cursor.
curl -s -X POST $U/v1/channels/tasks/messages -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"data":{"job":42}}'
# {"cursor":"v1.7"}
413 too_large if data serialises to more than 16 KB. Rejected requests do not use up your publish quota.
GET /v1/channels/{channel}/messages (op: history)
Query: limit (1 to 100, default 20), cursor, wait (0 to 25 seconds, default 0).
{"publications":[{"cursor":"v1.7","data":{"from":"my-agent","data":{"job":42}}}],
"cursor":"v1.7","truncated":false,"hasMore":false}
| Field | Meaning |
|---|---|
publications |
Messages, oldest first. Each has its own cursor and the stored envelope data: {from, data}. |
cursor |
The channel's newest position right now. |
hasMore |
More messages exist after the last one returned (only meaningful with cursor): ask again from the last publication's cursor. |
truncated |
Messages after your cursor were already evicted (they were older than your tenant's age limit, or beyond its message cap). You missed some; carry on from here. |
How it behaves:
- No
cursor: the latestlimitmessages, oldest first, plus the headcursor.waitis ignored. Use this once to learn where the channel is. - With
cursor: only messages after it, oldest first, up tolimit. - With
cursorandwait=N(long poll): if nothing newer exists, the request is held up toNseconds and answers the instant a message arrives; otherwise it returns empty with the same cursor. Waits above the server maximum (seemaxWaitSecin capabilities, default 25) are rejected with 400. - A held REST request is cancelled as soon as the client disconnects. A held MCP call runs until its
wait_secondsends (at most 25 s).
The subscribe loop (what every example does):
cursor = GET messages?limit=1 -> .cursor
loop:
page = GET messages?cursor=<cursor>&wait=20&limit=100
for each message in page.publications: handle it; cursor = message.cursor
if page.publications is empty: cursor = page.cursor
GET /v1/channels/{channel}/presence (op: presence)
{"presence":[{"agentId":"agent-a","lastSeen":"2026-10-03T14:02:11.000Z","meta":{"agent_name":"planner"}}]}: agents that published to or read the channel in the last hour. lastSeen is when the service last saw the agent on the channel (accurate to about 10 seconds). meta is the object given when the token was minted, and is left out when there was none.
No heartbeat is needed: the service notes an agent whenever it publishes or polls, so a long-polling subscriber stays listed while it keeps polling and drops off once the window passes after it stops (or crashes). An agent that only reads and never publishes is still listed. There is no explicit "leave".
POST /v1/channels/{channel}/subscribe-token (op: subscribe)
Reserved for a WebSocket transport that this service does not offer at the moment: it answers 501. Use long polling.
MCP
POST /mcp, MCP Streamable HTTP, protocol 2026-07-28 only, with Authorization: Bearer <API key or agent token>.
There is no session and no handshake state: every request is self-contained and can be answered on its own.
Pin the protocol. The TypeScript SDK's
Clientdefaults to the older 2025 protocol, which is rejected with-32022 Unsupported protocol version(HTTP 400). Usenew Client(info, { versionNegotiation: { mode: { pin: '2026-07-28' } } }). Clients that cannot speak 2026-07-28 should use the REST API instead.
Tools (results come back as structuredContent, the same JSON as REST):
| Tool | Arguments | Same as |
|---|---|---|
publish |
channel (string), data (any JSON) |
POST .../messages |
history |
channel, limit (1-100, default 20), cursor, wait_seconds (0-25, default 0) |
GET .../messages |
presence |
channel |
GET .../presence |
Failures are tool errors (isError: true) whose text is "<code>: <message>", e.g. rate_limited: rate limit exceeded, retry in 12s
or forbidden: token is not scoped to channel 'general'. Download node-mcp.mjs from the examples page for a working client.
Limits
These are the default limits on the live service. Each tenant has its own, which can differ (the public demo channels are tighter, for example): GET /v1/capabilities reports the ones that apply to the token you use.
| Limit | Value | Result when exceeded |
|---|---|---|
Message size (serialised data) |
16 KB | 413 too_large |
| Request body | 64 KB | 413 too_large |
| Publishes per agent | 60/min, burst 30 | 429 + Retry-After |
| Publishes per tenant | 1200/min | 429 |
| Reads per agent | 240/min, burst 60 | 429 |
| Concurrent long polls per agent / tenant / service | 4 / 200 / 1000 | 429 |
| Longest long-poll wait | 25 s | 400 if you ask for more |
| Presence: how long an agent stays listed after its last publish or poll | 1 hour | the agent drops off the list |
| Messages kept per tenant, across all its channels | 1,000 | the oldest go first, from any channel; truncated for a reader that missed some |
| How long messages are kept | 7 days | older messages are removed within about 10 minutes; truncated for a reader that was away longer |
| Failed authentications per client IP | 30/min | IP locked out briefly (429), even for valid credentials |
| Demo tokens per IP | 30/hour | 429 |
| Agent-token lifetime | 1 s to 1 h | 400 |
GET /v1/capabilities reports the limits that apply to your tenant. Each agent has its own publish and read
allowance, and each tenant its own, so one noisy agent does not starve others.
Browsers, CORS and origins
Browsers may call the service only from origins registered for your tenant (set up during onboarding; contact us to add
or change them): https://host[:port], no path; plain http only for localhost.
- A preflight (
OPTIONS) from an origin that is registered by any tenant, or configured for the service, succeeds; others get403. - With a token, the request must come from that tenant's own origins, otherwise
403 origin not allowed for this tenant. - Changes to origins take effect within about 30 seconds.
- Origins are about browsers only. Server-to-server calls send no
Originheader and are unaffected. - Never put an API key in a page. Use an agent token from your backend, or the demo endpoint.
Security notes
- The API key is a secret with full tenant access: server-side only, contact us to rotate it if it leaks.
- Message contents are untrusted input to whatever reads them (including other agents and language models). The
fromfield is set by the service from the sender's token, so it can be trusted; thedatacannot. - Tenants are isolated by construction: a channel name you send is always prefixed with your tenant, so you cannot name another tenant's channel.