HTTP API
The SDKs for TypeScript, Go and Python wrap this API. Use it directly from any other language: other languages says what your server does. Every path below is under https://app.wakeshift.ai/api/apps/v1, takes and returns JSON, and answers errors as { "error": { "code", "message" } }. A request body field this page doesn't list is a 400.
Authentication
Your key is an Ed25519 private key. Don't send it. Instead, send a fresh signed assertion with each request:
POST /api/apps/v1/events/emit
Authorization: Bearer <assertion>
Content-Type: application/jsonThe assertion is a compact JWS:
- Header:
{"alg": "EdDSA", "typ": "app+jwt"}. - Claims:
issandsubare your app's ID;audis Wakeshift's URL without a trailing slash, plus/api/apps(https://app.wakeshift.ai/api/apps);iatis now, in whole seconds (an integer);expisiat + 120;jtiis 16 random bytes, base64url. - Signature: Ed25519 over
base64url(header) + "." + base64url(claims). All base64url is without padding.
Wakeshift refuses an assertion that has expired, lives longer than 5 minutes, is issued more than a minute in the future, has no jti, or reuses one.
To read the key wst_test_<app>_<seed>: strip wst_, split on the first _ (the mode) and the next _ (the app ID). The rest is the 32-byte seed, base64url without padding. App IDs never contain _; seeds can.
The caller
Calls made for a conversation (subscribing it, unsubscribing it, annotating its results) carry a call object, built from the tool call:
{ "user": "u_123", "client": "Cursor", "meta": { }, "user_agent": "Cursor/1.0.0", "session": "<session key>", "harness": "grok-bot", "conversation": "bc-…" }| Field | What it is |
|---|---|
client |
The MCP client's name, clientInfo.name from initialize. It tells Wakeshift which agent this is: claude-code, codex-mcp-client, Cursor, copilot-cli, opencode, antigravity-client, and others. Send it whenever you have it: it wins over meta and user_agent. A stateless server doesn't have it: see stateless servers. |
meta |
The tool call's _meta, as the client sent it. Codex sends its thread ID there. Without client, it tells Wakeshift Claude Code, Codex and Antigravity apart. |
user_agent |
The HTTP request's User-Agent header. Without client, it tells Wakeshift opencode, Cursor and GitHub Copilot CLI apart. |
session |
The session_key argument the agent passed back, if any. It's an opaque key: pass it on unchanged. |
harness |
The harness argument: the program the agent says it runs in. A hint, not a credential: it only refines what client, meta and user_agent say (a Cursor call may be Grok Bot's or a Cursor cloud agent's), or names the agent when they say nothing. One that contradicts them is ignored. Pass it on unchanged. |
conversation |
The conversation_id argument, if any. Cursor tells MCP servers nothing about the conversation, so its agents (and Grok Bot) pass their CURSOR_CONVERSATION_ID. Pass it on unchanged. |
user |
Optional. Your own ID for the person (what it can contain). |
client is at most 200 characters, session at most 100 and user at most 200. Longer is a 400.
Give a tool of yours that subscribes the caller (and any other tool whose results you annotate) three optional string arguments, described this way, and leave them out of required. The subscribe_events tool has them too:
"session_key": { "type": "string", "description": "The session key an earlier result gave you, if it did. It's a secret: keep it to this conversation; don't share or log it" },
"harness": { "type": "string", "description": "The program you're running in: codex, claude-code, cursor (local CLI or IDE), cursor-cloud (Cursor cloud agent), grok-bot, copilot-cli, opencode, antigravity, openclaw (even when it runs your turns in Codex), hermes, chatgpt, or your program's name if it isn't listed" },
"conversation_id": { "type": "string", "description": "Your own ID for this conversation, if your program gives you one. Cursor and Grok Bot: the output of `printenv CURSOR_CONVERSATION_ID`. Codex and Antigravity send it automatically; others leave it empty" }Handling secrets
Some of what passes through your server is a credential for one conversation. Anyone who has it can act as that conversation: read its events, subscribe it to more, or end its subscriptions.
- the
session_keyargument and thesessiona subscribe returns; addition.textandaddition.meta, which can hold what lets that conversation receive its events.
Don't log tool arguments or tool results that carry them, don't put them in error reports, and don't show them on a page. To name a conversation where people can see it, use subscription.id, or an HMAC of the session key under a secret of your own. Your key never leaves your server; publish URLs are credentials too.
Events
Events are the main way to offer subscriptions. Declare them in your manifest, let agents subscribe, and emit occurrences.
| Endpoint | Body | Returns |
|---|---|---|
PUT /manifest |
{ description?, …, events: [{ name, description, title?, arguments, payload, topic, filter?, personal? }] } |
The manifest as Wakeshift keeps it. arguments and payload are JSON Schemas (2020-12). |
POST /events/emit |
{ name, arguments, payload, text, id?, to? } |
{ matched, duplicate } |
POST /event-subscriptions |
{ call, name, arguments, once?, replay?, ttl? } |
{ event_subscription: { id, event, arguments, created, expires_at }, session, addition: { text?, meta? }, agent } |
POST /event-subscriptions/unsubscribe |
{ call, name, arguments } |
{ ended } |
POST /event-subscriptions/list |
{ user } |
{ subscriptions: [{ id, event, arguments, destination, webhook_host?, created_at }] } |
POST /event-subscriptions/end |
{ user, id } |
{ ended } |
POST /events/subscribe |
{ call, name, arguments, delivery, dialect, ttl_ms?, cursor?, max_age_ms? } |
{ id, refresh_before, cursor, truncated, delivery_status? } |
POST /events/unsubscribe |
{ call, name, arguments, delivery, dialect } |
{ ended } |
- Event types.
topicis the template occurrences route by:ev/<name>/{arg}…, one segment per required string, number or boolean argument.filtermaps a payload field to the optional argument it must equal. Withpersonal: true, each subscription belongs to its subscriber andemitneedsto. The SDKs build all of this from the event's definition. - Emit.
payloadmust match the event's schema;textis at most 1000 bytes; the sameidwithin a day emits once (duplicate: true).tonames a personal event's subscriber, yourcall.userfor them. - Bridged subscriptions (
/event-subscriptions) subscribe the calling conversation, assubscribe_eventsdoes. Putadditionin your tool's result, as for topics below.listandendact on one of your users' subscriptions, whatever their destination. - Native subscriptions (
/events/subscribe) are what your server forwards when a client sends MCP Events'events/subscribe: pass the client'sdeliveryobject as it sent it, anddialect,"chatgpt"for ChatGPT and"draft"for every other client. Wakeshift checks the client's callback, then delivers occurrences to it, signed and in order.
Topics
Topics are what events are built on.
| Endpoint | Body | Returns |
|---|---|---|
POST /topics/subscribe |
{ call, topic, filter?, once?, replay?, ttl? } |
{ subscription: { id, topic, filter?, once, expires_at }, session, addition: { text?, meta? }, agent: { harness, via, claimed } } |
POST /topics/unsubscribe |
{ call, topic? } or { user, topic? } |
{ unsubscribed: n, unknown_session? } |
POST /topics/publish |
{ topics: [...], event: { id?, text, data? } } |
{ matched, duplicate } |
POST /topics/annotate |
{ call } |
{ text?, meta?, agent: { harness, via, claimed } } |
GET /topics/status?topic=… |
None | { topic, subscriptions, connected, via: { stream, poll, hooks, webhook } } |
POST /topics/close |
{ topic } |
{ closed: n } |
After a subscribe, put addition.text after your own text in the tool result, and addition.meta in the result's _meta. The same goes for annotate, on the results you choose to annotate. A separate text content block after yours works, and so does your text, a blank line, then addition.text in one block (the SDK does the first for a result, the second for a string). In most MCP frameworks, setting _meta means returning a full CallToolResult.
agent is what Wakeshift recognised the caller as: harness (claude-code, codex, cursor, cursor-cloud, grok-bot, copilot, opencode, antigravity, …) and via ("client", "meta", "user_agent", or "claim" when only call.harness named it), both null when it recognised nothing; and claimed, what call.harness said, normalized ("Grok Bot" is grok-bot), or null. A claimed that differs from harness was ignored. When harness and claimed are both null, the agent can't be woken: log a warning.
unsubscribe takes call, to end the calling conversation's subscriptions, or user, to end every subscription of one of your users when they lose access. Passing both is a 400. Without topic, it ends them on all your app's topics. With call, unknown_session: true says Wakeshift doesn't know the session key, so nothing ended.
publish takes 1 to 20 topics. event.text is at most 1000 bytes, and event.data is a JSON object, within the 64 KB body limit. event.id (at most 200 characters) makes retries safe: the same id within a day publishes nothing and returns the first publish's matched with duplicate: true. After a day, the same id publishes again. matched counts the conversations the event reached.
filter is up to 20 top-level fields of the event's data, each name at most 64 characters, each equal to a string, number, boolean or null of at most 256 bytes as JSON.
ttl and replay are seconds as a number, or strings like "90s", "5m", "2h" and "30d" (days are whole numbers).
Subscriptions
| Endpoint | Body | Returns |
|---|---|---|
POST /subscriptions/{id}/send |
{ event } |
{ status: "delivered" | "queued" | "closed" } |
GET /subscriptions/{id} |
None | { id, topic, status: "active" | "closed", connected, via?, expires_at? } |
POST /subscriptions/{id}/end |
{} |
{ ended }: true if it ended the subscription, and its conversation is told; false if it had already ended or isn't your app's. |
send and GET answer 404 unknown_subscription for an ID that isn't your app's.
Publish URLs
| Endpoint | Body | Returns |
|---|---|---|
POST /topics/publish-urls |
{ topic } |
{ id, topic, url } |
POST /events/publish-urls |
{ name, arguments, to? } |
{ id, event, url } |
DELETE /events/publish-urls/{id} |
None | { revoked: true } |
The URL itself takes no authentication. A topic's takes any body: see publish URLs. An event's takes the event's payload, a JSON object that matches its schema, and answers 400 bad_payload for anything else, or 410 unknown_event once your app no longer offers the event; to is required for a personal event, as for emit. DELETE revokes either kind (DELETE /topics/publish-urls/{id} still works for a topic's).
Users
| Endpoint | Body | Returns |
|---|---|---|
POST /users/forget |
{ user } |
{ forgotten }: true if there was something to delete. See forgetting a user. |
Errors
| Status | Code | When |
|---|---|---|
400 |
bad_request |
Something in the request is invalid, including a body field this page doesn't list. The message says what. |
401 |
unauthenticated |
The assertion was refused: expired, reused, signed with another key, or for another aud (a key for another Wakeshift). The message says which. |
400 |
bad_arguments |
An event's arguments don't match its schema, or a routing value isn't a lowercase ID. The message says which. |
403 |
private_topic |
Subscribing a caller to another user's u/<user>/… topic. |
403 |
no_grant |
An app on Wakeshift accounts: the person's consent for your app, for that agent, is revoked or has expired. |
403 |
other_client |
The session key is a conversation in another MCP client of the same user. Call again without it. |
403 |
not_headless |
Topics need an app whose users are its own. |
404 |
unknown_subscription |
The subscription ID isn't one of your app's. |
404 |
unknown_publish_url |
The publish URL ID isn't one of your app's. |
404 |
unknown_event |
Your app offers no event by that name. |
413 |
too_large |
The request body is over 64 KB. The limit is on the whole body, so data must leave room for the rest of the request. |