Who may subscribe
Every event answers one question: who may subscribe to it? By default, only your own tools can. An event opens to other paths only when it says so, with personal, authorize or public, and Wakeshift enforces that on its side too.
One question: do you sign people in?
- No. Keep the default. Your tools subscribe the conversation calling them, to IDs your tool minted or checked (jobs, crawls, runs), and nobody else can subscribe at all.
- Yes. Use
personalfor things that belong to one user (their turn, their messages), andauthorizefor shared resources the caller must be allowed to see (a repo, a project). publicis for the rare stream anyone may watch.
Either way, step 0: if you have an identify, it reads a credential your server verified, never an unverified header.
| The event declares | A tool that subscribes | subscribe_events |
Native subscriptions (events/subscribe, events/poll) |
Use it for |
|---|---|---|---|---|
| nothing (the default) | ✓ | refused, and not listed | only with a voucher your tool handed out (below); refused otherwise | IDs your tool mints or checks: jobs, crawls, runs |
personal: true |
✓ | ✓ (the caller's own) | ✓ (the caller's own; needs a user) | Things that belong to one user |
authorize |
✓ | ✓ if authorize allows |
✓ if authorize allows, on every request |
Shared resources the caller must be allowed to see |
public: true |
✓ | ✓ | ✓ (needs a user) | Truly public streams |
public can't be combined with personal or authorize: the SDK refuses it when you define the event.
Shared resources: authorize
const ciFinished = wake.defineEvent("ci.finished", {
description: "CI finished on a GitHub repo",
args: z.object({ owner: z.string(), repo: z.string() }),
payload: z.object({ branch: z.string(), conclusion: z.string() }),
authorize: (subscriber, { owner, repo }) => canSeeRepo(subscriber.authInfo, `${owner}/${repo}`),
})ci_finished = wake.define_event(
"ci.finished",
description="CI finished on a GitHub repo",
args=Repo,
payload=Run,
authorize=lambda subscriber, a: can_see_repo(subscriber.user, f"{a.owner}/{a.repo}"),
)wake, err := wakeshift.New()
if err != nil {
log.Fatal(err)
}
type repoArgs struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
}
type ciRun struct {
Branch string `json:"branch"`
Conclusion string `json:"conclusion"`
}
ciFinished, err := wakeshift.DefineEvent(wake, "ci.finished", wakeshift.EventDef[repoArgs, ciRun]{
Description: "CI finished on a GitHub repo",
Authorize: func(ctx context.Context, s wakeshift.Subscriber, a repoArgs) (bool, error) {
return canSeeRepo(s.User, a.Owner+"/"+a.Repo), nil
},
})
if err != nil {
log.Fatal(err)
}
_ = ciFinishedauthorizedecides every subscription made throughsubscribe_events, and every nativeevents/subscribeandevents/poll, about every ten seconds for each native subscriber. Cache allows in your own layer, keyed by user and arguments, if the check is expensive.- For a client that subscribes natively, such as ChatGPT, the SDK also asks
authorizebefore your tool tells it what to subscribe to. A refusal makescall.subscribereturnstate: "unavailable"withreason: "refused", and the client is told nothing. - It gets the subscriber:
user(from youridentify), the caller's verified token (TypeScriptauthInfo; Python and Gorequest), andclientandnative. Never decide onclientornative: the client asserts both. - A throw, an error, or a run over 10 seconds refuses the subscription, and the SDK logs
authorize_failedwith the error's type. - Your own tools don't consult
authorize: a tool that subscribes to a resource the caller names checks the caller can see it, in its own code.
Resources a team shares
When a resource belongs to a team (an organization's project, a shared workspace, a channel), put the team or the resource in the event's arguments and check membership in authorize, from your own records and the caller's verified sign-in: authorize: async (subscriber, { team }) => !!subscriber.user && (await isMember(subscriber.user, team)), where isMember is your own code. Every member who subscribes with those arguments gets each occurrence you emit with them, and nobody else can subscribe. Keep such an event shared, not personal: a personal event goes only to the users each emit names, so it suits what belongs to one person. Cache membership in your own layer if the check is slow, since native clients' polls ask authorize about every ten seconds. When someone leaves the team, end their subscriptions yourself (when someone loses access): a subscription made through subscribe_events isn't checked again.
Things that belong to one user: personal
A personal event's subscriptions each belong to the user who made them, and an emit goes only to the users you name. Nothing in the arguments names the subscriber, so nobody can subscribe to someone else's.
const yourTurn = wake.defineEvent("game.turn", {
description: "It's your move at a table",
args: z.object({ table: z.string() }),
payload: z.object({ toCall: z.number() }),
personal: true,
})
await yourTurn.emit({ table: "juniper" }, { toCall: 2 }, { to: "player-abc123" })to comes from your own ownership record: your seating table, your membership list, who owns the order. Never take it from the subscriber list, and never from anything the caller sent. A personal event needs a signed-in user to subscribe, so pass identify to attach, and requireUser: true whenever your server requires sign-in. More on personal events and emitting to many.
Don't close native subscriptions with authorize
An authorize that always returns false, written to keep clients from subscribing natively, does what the default already does, and reads as if the event were open. Remove it and leave the event at the default.
ChatGPT and other clients that subscribe natively
ChatGPT is woken only through MCP Events: it subscribes itself and receives each occurrence as a webhook. Claude Code, Codex and other agents are woken through your tools.
| The event | ChatGPT and other MCP Events clients | Claude Code, Codex and other agents |
|---|---|---|
| The default | Served through a subscribe voucher: when your tool subscribes it, its result hands the client a one-time voucher for exactly that event and those arguments, which it uses to subscribe within 4 hours. No sign-in needed; with sign-in, only the same user can use it. call.subscribe returns state: "native" |
Through your tools |
personal |
Served, to a signed-in user | Your tools and subscribe_events |
authorize |
Served if authorize allows, asked before your tool's reply and on every request |
Your tools and subscribe_events |
public |
Served, to a signed-in user | Your tools and subscribe_events |
| Your server serves its own MCP Events | Gets your server's own events, not Wakeshift's | Unchanged |
So ChatGPT is served whatever you choose, on a server it can reach: through your tools' vouchers by default, or directly through personal, authorize or public. ChatGPT connects only to a remote server over HTTP, never to a stdio one, and the Python SDK serves MCP Events only on mcp 2.x hosts (MCPServer, FastMCP 4, the low-level Server), not on FastMCP 2 or 3. ChatGPT reads your events list when it connects, so a user connected before an event became available sees it after Refresh tools in ChatGPT's plugin settings. The SDK logs native_unavailable once per tool and reason, so you see any client your events don't reach.
How a voucher works:
- It's in your tool's result, in the text Wakeshift adds (and in
structuredContent.wakeshiftwhen your tool returns structured content):wsv_and 32 characters. You write nothing for it. - The client sends it back as the event argument
wakeshift_voucherinevents/subscribe, with a webhook.events/listshows your tools-only events to MCP Events clients with that argument required. Theirdeliveryiswebhookonly:events/pollof a tools-only event is refused, voucher or not. The SDK takes the voucher out of the arguments beforeauthorize,onSubscribeor your logs see them, sowakeshift_voucheris a reserved argument name. - It creates one subscription, to exactly that event and those arguments, and lets the client renew and end it. The subscription keeps what your tool's
call.subscribeasked for:onceends it at its first delivery, andttlbounds it. It starts with the latest occurrence since your tool handed out the voucher (or withinreplay, if that reaches further back), so an emit between your tool's reply and the client's subscribe isn't lost. - Using it again refreshes that subscription, never makes a second. While the subscription is live,
events/subscribewith the same voucher and the same webhook URL renews it: for an anonymous caller only with the same secret, and for a signed-in user only as that user, who may send a new secret to replace the old one. Once the subscription has ended (after aoncedelivery, say), the voucher is spent. - Anything else is refused with MCP Events' forbidden error: a spent or expired voucher, one for other arguments or another user, another webhook URL, and a tools-only event without a voucher. The error is
-32012for ChatGPT (client name or User-Agentopenai-mcp) and-32024for every other client; the message says the voucher "was used, it expired, or it's for other arguments". - A signed-in user has at most 20 live webhook subscriptions to your app (
events/subscribe), vouchers' andpersonal,authorizeorpublicones together. Past that, a new one is refused with MCP Events'ResourceExhaustederror (InvalidParams,-32602, for ChatGPT) until one ends or lapses. Aoncesubscription stops counting when it delivers, and renewing one is never refused. onSubscriberuns when the client subscribes with the voucher, withvia: "tool"and your tool's name, as for any subscription your tool made, and with thecontextyour tool passed tocall.subscribewhen this process handed out the voucher (a redemption on another instance gets none).
Upgrading from an earlier SDK
This rule arrives with TypeScript SDK 0.11, Python SDK 0.10 and Go SDK v0.10. Before you upgrade, nothing changes: Wakeshift keeps treating your events as open to every path, exactly as today.
When a new SDK first publishes your app's events, each event that declares neither personal, authorize nor public becomes tools-only, and Wakeshift ends the subscriptions that were made to it through subscribe_events or natively. Subscriptions your tools made keep working. The SDK logs event_closed at warn level for each such event, with how many subscriptions ended and how to reopen it.
- If only your tools ever subscribed (the common case), do nothing: the log says 0 subscriptions ended.
- To keep other paths open, add
authorize(gated) orpublic: true(open to anyone) before you deploy. - If ChatGPT subscribes to those events on its own, without your tool handing it a voucher, decide before you deploy: add
authorizeorpersonal, or those subscriptions end. Subscriptions your tools hand ChatGPT vouchers for keep working on the default.
Reopening an event later takes a new SDK publishing authorize, personal or public: true for it, and Wakeshift records the change.
A worked example
A shopping server offers product.price.changed and product.availability.changed, keyed by a watch ID its own watch_product tool mints. It has no sign-in, and agents subscribe only through watch_product.
- On the new SDK, both events are tools-only, which is what it meant. Nothing that matters ends: no agent subscribed any other way. Its first deploy logs
event_closedfor both events with 0 subscriptions ended. - ChatGPT gets
state: "native"fromwatch_product, with no sign-in: the tool's result hands it a voucher for that watch ID, and ChatGPT subscribes with it. A guessed watch ID without a voucher is refused. - If it adds sign-in later, nothing changes for ChatGPT: each voucher is then bound to the signed-in user. To let ChatGPT subscribe without calling
watch_productfirst, it addsauthorize, checking that the watch ID belongs to the caller.public: truewould also work, but opens the events to anyone who learns a watch ID.