Events
An event is something your app offers to wake agents with, such as ci.finished or chat.message. You define each event once, serve the events on your MCP server, and emit an occurrence whenever it happens. Every agent subscribed with matching arguments wakes up.
Events follow MCP Events, the Model Context Protocol's standard for this. Clients that speak it, such as ChatGPT, subscribe natively. Every other agent subscribes with a tool the SDK adds, and Wakeshift wakes it the way that agent can be woken. Your code is the same for all of them.
Define an event
const message = wake.defineEvent("chat.message", {
description: "A new message in a channel",
args: z.object({ channel: z.string(), from: z.string().optional() }),
payload: z.object({ from: z.string(), text: z.string() }),
authorize: (subscriber, { channel }) => canRead(subscriber.user, channel),
text: (m, { channel }) => `${m.from} in #${channel}: ${m.text}`,
})| Option | What it is |
|---|---|
| name | What clients subscribe to: lowercase words joined by dots (chat.message, game.turn), at most 64 characters, not starting with wakeshift.. |
description |
What it is, for the agent and for the person who approves the subscription. |
args |
What a subscriber picks, as a zod object. Its required string, number and boolean fields route occurrences: an occurrence reaches the subscribers who picked the same values. Their values are lowercase IDs (a-z 0-9 . _ -). An optional field filters: a subscriber who sets it only gets occurrences whose payload field of the same name has that value. |
payload |
What each occurrence carries, as a zod object. emit checks it before sending. |
text |
The line the agent reads first, from the payload and arguments. |
authorize |
Who may subscribe with these arguments. Wakeshift refuses the subscription when it returns false. Default: everyone. |
personal |
Each subscription belongs to its subscriber, and each occurrence goes to one subscriber. See personal events. |
deleteOnDelivery |
Wakeshift deletes each occurrence's text and payload once it's delivered, keeping only that it happened (when, and where it went). An agent can't have it again after that: a replay skips it. An occurrence that's never delivered expires as any other does. Use it for messages people wouldn't expect a third party to keep, such as chat messages. |
Define every event before serveEvents. Your events go into your app's manifest the first time you serve or emit one.
Serve them
const server = new McpServer({ name: "chat", version: "1.0.0" })
wake.serveEvents(server, { user: (extra) => extra.authInfo?.extra?.userId as string | undefined })serveEvents answers MCP Events requests (events/list, events/subscribe, events/unsubscribe) for clients that speak it, and adds three tools:
subscribe_events, for every agent that doesn't subscribe natively. ChatGPT never sees it.unsubscribe_eventsandlist_subscriptions, for every agent, so a person can always see and stop what they're subscribed to by asking their agent.
The tools' wording is fixed, and the same in every SDK. Keep one McpServer per MCP session (a transport with sessions), and call serveEvents before connecting it.
user is your own ID for the person behind a request; subscriptions belong to that user. By default it's the guest that guest sign-in signed in. Native subscriptions and personal events need a user; other agents can subscribe to shared events without one.
Emit
const { matched, duplicate } = await message.emit(
{ channel: "eng" },
{ from: "ada", text: "shipped it" },
{ id: deliveryId },
)- The routing arguments say who gets it: here, everyone subscribed to
eng. Optional (filter) arguments aren't passed toemit; they match against the payload. idmakes retries safe: the sameidwithin a day emits once, and the retry returnsduplicate: true.matchedcounts the subscriptions the occurrence reached.- The text is at most 1000 bytes, and the whole request at most 64 KB.
Personal events
A personal event is for one subscriber at a time, so an occurrence can carry something only they may see: their cards in a game, their own messages.
const turn = wake.defineEvent("game.turn", {
description: "It's your move at a table",
args: z.object({ table: z.string() }),
payload: z.object({ hole: z.array(z.string()), toCall: z.number() }),
text: (t) => `Your move: ${t.toCall} to call`,
personal: true,
})
await turn.emit({ table: "juniper" }, { hole: ["As", "Kd"], toCall: 2 }, { to: "guest:abc123" })tois required for a personal event, and refused for any other. It names the subscriber: theuserserveEventsgave for them.- Nothing in the arguments names the subscriber, so nobody can subscribe to someone else's: Wakeshift takes who's subscribing from their own sign-in.
Emit from a webhook
When a service that can only send webhooks should emit one of your events, give it an event's publish URL. Each POST to it emits the event with the arguments you made the URL for:
const { id, url } = await message.publishUrl({ channel: "eng" })- The body is the payload: a JSON object that matches the event's
payloadschema. Anything else gets400 bad_payload, and nothing is emitted. - Your
textfunction doesn't run, since your code isn't involved. The agent reads the event's name and the start of the payload:chat.message: {"from":"ci","text":"deployed"}. - For a personal event, pass
{ to }: the URL emits to that one subscriber. wake.revokePublishUrl(id)turns it off; it answers410from then on, and also once your app stops offering the event.- Sizes, rate limits and retries work as for publish URLs: 64 KB, 60 posts a minute, and an
Idempotency-Keyheader to emit once.
When the sender's body doesn't match your payload, receive the webhook in your own handler and call emit from there.
Subscribe from your own tools
A tool like "join a table" can set up the caller's wake-ups in the same step, instead of asking the agent to call subscribe_events afterwards:
server.registerTool("join", { inputSchema: { table: z.string(), ...wake.args } }, async ({ table, ...wakeArgs }, extra) => {
const user = extra.authInfo?.extra?.userId as string
const sub = await turn.subscribe(extra, { table }, { server, user, wakeArgs })
return sub.result(`You're seated at ${table}.`)
})event.subscribe(extra, args, { server, user, wakeArgs })subscribes the conversation that called your tool. It returns the subscription'sidandresult(text): your text, with what that agent needs in order to be woken. Your tool decides who may;authorizeisn't consulted.- Spread
...wake.argsinto the tool's input and pass them on aswakeArgs: they tell Wakeshift which conversation is calling. - Clients that speak MCP Events subscribe themselves; tell them which event and arguments to subscribe to.
event.unsubscribe(extra, args, …)ends the caller's subscription with those arguments, for a tool like "leave".event.end(user, id)ends one of a user's subscriptions by its ID.
When a subscription ends
| Ended by | What happens |
|---|---|
The agent calls unsubscribe_events, or a native client unsubscribes |
It's the agent's own request. |
Your code calls event.unsubscribe or event.end |
The conversation is told the subscription ended. |
| No delivery or fresh subscribe for 30 days | It expires, and the conversation is told. A native client renews its own subscriptions. |
| You forget the user | Their subscriptions end and their streams close. |
| A person revokes your app in their Wakeshift console (apps on Wakeshift accounts) | Its subscriptions end at once. |
Occurrences emitted before a subscription ended still reach the conversation, ahead of the notice.
How occurrences reach the agent
An occurrence is marked as information from outside, never as an instruction from the agent's user, and stamped with your app as the sender. If the conversation isn't connected, it waits. Wakeshift paces wake-ups for each agent. How quickly each agent wakes, and what its user approves, is on the agents page.
Topics
Events are built on topics, which you can also use directly: a topic's publish URL, which takes any body, and waking one conversation by its subscription ID, are topic features.