@wakeshift/sdk 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,170 @@
1
1
  # Changelog
2
2
 
3
+ What changed in each release of `@wakeshift/sdk`. 0.4.0 is the first published release: to start with it, read the [README](README.md). Its notes below, and the earlier releases', are for apps that ran the preview builds.
4
+
5
+ ## 0.5.0 (2026-10-29)
6
+
7
+ ### Breaking
8
+
9
+ - **`recognised` is now `recognized`.** The `Recognised` type is `Recognized`, and `Subscription.recognised` and `ToolCall.recognised` are `recognized`. Rename them where you read them: `sub.recognised.harness` becomes `sub.recognized.harness`. What they hold is unchanged.
10
+
11
+ ### Fixed
12
+
13
+ - **Processes that share your key no longer remove each other's events.** In 0.4.0 every process sent the events it defined as your app's whole list. With an MCP server and a separate emitter on one key, whichever sent last removed the other's events, and the other's emits failed with `unknown_event` until it restarted. Now only your MCP server, the process that calls `attach`, sends the manifest automatically, and Wakeshift merges it into the one it has: it adds or updates your events and the settings you passed to `publishManifest`, and never removes an event or clears a setting. A process that doesn't call `attach` never sends it, whatever it calls (`emit`, `status`). `publishManifest` still replaces the whole manifest, and is how you remove an event.
14
+ - A process with no events defined never sends the manifest. In 0.4.0 any app call from one (even a status read) sent an empty list, which removed every one of your app's events.
15
+ - If an event is defined only in a process that emits it, define it in your MCP server too (or publish it with `publishManifest`): until a server sends it, emitting it fails with `unknown_event`.
16
+
17
+ ## 0.4.0 (2026-10-29)
18
+
19
+ Define your app's events (`defineEvent`), attach Wakeshift to your MCP server (`attach`), subscribe the conversations that call your own tools (`attached.events`, `call.subscribe`), and emit to them (`emit`). The rest of this section is what changed since 0.3.0.
20
+
21
+ ### Breaking
22
+
23
+ One way to subscribe from your own tools. Wrap the tool with the handle `attach` returns, and subscribe with `call.subscribe`: Wakeshift adds what the agent needs to be woken to whatever you return, once per call however many subscriptions it makes, and ends the subscriptions of a call that fails. The tool can't leave a conversation unwakeable by a forgotten argument, a wrong tool name or a result it didn't pass through.
24
+
25
+ A join tool:
26
+
27
+ ```ts
28
+ // before
29
+ server.registerTool("join", { inputSchema: { table: z.string(), ...wake.args } }, async ({ table, ...wakeArgs }, extra) => {
30
+ const sub = await turn.subscribe(extra, { table }, { server, user: myUser(extra), wakeArgs, tool: "join" })
31
+ return sub.result(`You're seated at ${table}.`)
32
+ })
33
+
34
+ // now
35
+ const attached = wake.attach(server, { identify: (extra) => ({ user: myUser(extra) }) })
36
+ server.registerTool(
37
+ "join",
38
+ { inputSchema: attached.inputs({ table: z.string() }) },
39
+ attached.events("join", async ({ table }, call) => {
40
+ const sub = await call.subscribe(turn, { table })
41
+ // Keep sub.user and sub.id to reach this conversation: emit's to: [{ user, subscription: id }]
42
+ return `You're seated at ${table}.`
43
+ }),
44
+ )
45
+ ```
46
+
47
+ A tool with structured output: make its `outputSchema` with `attached.output(shape)`, and the wrapper sets its `wakeshift` field to what Wakeshift adds, for hosts that show the model only `structuredContent`. Wakeshift's text stays in `content` too.
48
+
49
+ ```ts
50
+ // before: sub.result(...) added Wakeshift's text to content only
51
+ // now
52
+ server.registerTool(
53
+ "look",
54
+ { inputSchema: attached.inputs({ table: z.string() }), outputSchema: attached.output({ seats: z.number() }) },
55
+ attached.events("look", async ({ table }, call) => {
56
+ await call.subscribe(turn, { table })
57
+ return { content: [{ type: "text", text: "3 seats." }], structuredContent: { seats: 3 } }
58
+ }),
59
+ )
60
+ ```
61
+
62
+ A leave tool:
63
+
64
+ ```ts
65
+ // before
66
+ server.registerTool("leave", { inputSchema: { table: z.string(), ...wake.args } }, async ({ table, ...wakeArgs }, extra) => {
67
+ await turn.unsubscribe(extra, { table }, { server, user: myUser(extra), wakeArgs, tool: "leave" })
68
+ return { content: [{ type: "text", text: `You left ${table}.` }] }
69
+ })
70
+
71
+ // now
72
+ server.registerTool(
73
+ "leave",
74
+ { inputSchema: attached.inputs({ table: z.string() }) },
75
+ attached.events("leave", async ({ table }, call) => {
76
+ const left = await call.unsubscribe(turn, { table })
77
+ return left ? `You left ${table}.` : `You weren't at ${table}.`
78
+ }),
79
+ )
80
+ ```
81
+
82
+ A tool you used to pass to `wake.annotate` is wrapped the same way, without a subscribe: the wrapper adds what Wakeshift has for the conversation calling it.
83
+
84
+ Topics are gone: use events. Define an event for what you published to a topic, subscribe from a wrapped tool (or let agents use `subscribe_events`), and emit it.
85
+
86
+ ```ts
87
+ // before
88
+ await wake.publish(`ci/${repo}`, { text: `CI ${run.conclusion} on ${repo}`, data: run, id })
89
+ const { subscriptions, connected } = await wake.topicStatus(`ci/${repo}`)
90
+
91
+ // now
92
+ const ciFinished = wake.defineEvent("ci.finished", {
93
+ description: "CI finished on a repo",
94
+ args: z.object({ repo: z.string() }),
95
+ payload: z.object({ conclusion: z.string() }),
96
+ text: (run, { repo }) => `CI ${run.conclusion} on ${repo}`,
97
+ })
98
+ await ciFinished.emit({ repo }, run, { id })
99
+ const { subscriptions, connected } = await ciFinished.status({ repo })
100
+ ```
101
+
102
+ `wake.send(id, …)` is an emit to one subscription of a personal event (`to: [{ user, subscription: id }]`), `wake.subscriptionStatus(id)` is `event.statusOf([{ user, subscription: id }])`, `wake.publishUrl(topic)` is `event.publishUrl(args)`, and `wake.endSubscription(id)` and `wake.unsubscribeUser(user, topic)` are `event.end(user, id)` for each subscription you keep.
103
+
104
+ Groups are gone: keep membership in your app and emit to your members' subscriptions. `AgentScope.groups` is removed; store the `{ user, id }` each member's `call.subscribe` returned and emit to them in one call, `to: [{ user, subscription: id }, …]`.
105
+
106
+ Errors the SDK throws before asking Wakeshift have the status and code Wakeshift gives for the same mistake, the same in the Go and Python SDKs:
107
+
108
+ - A payload that doesn't match the event's schema is `bad_arguments`, Wakeshift's code for an emit's payload, not `bad_payload`. `ArgsError`, for arguments or a payload that don't match, is a `WakeshiftError` with code `bad_arguments` (status 400): its `message` starts `wakeshift: bad_arguments (400): `, and `detail` is the message without it.
109
+ - `call.subscribe` or `call.unsubscribe` given another app's event is `unknown_event` (404), not `bad_arguments`.
110
+ - `statusOf` with more than 1,000 subscriptions, or one without its user, is `bad_request`, not `bad_arguments`.
111
+
112
+ **A manifest's `events` add to the ones you define.** `publishManifest`'s `events` used to replace the events `defineEvent` made. Now Wakeshift gets both, the listed ones first, and an event both defined and listed is an error: nothing is sent. List only events defined elsewhere.
113
+
114
+ **A `filter` may not name a field an optional argument filters.** Without a `topic`, `defineEvent` adds a filter for each optional argument to your `filter`; where both named the same payload field, the argument's entry silently replaced yours. Now `defineEvent` throws, naming the field: remove it from `filter`, or pass `topic` and `filter` both.
115
+
116
+ ### Removed
117
+
118
+ - `event.subscribe(extra, …)` and `event.unsubscribe(extra, …)`: `call.subscribe` and `call.unsubscribe` in a wrapped tool.
119
+ - `CallerSubscription` and its `result` and `agent`: `Subscription`, with `recognised`; the wrapper adds the block.
120
+ - Topics and their types: `wake.subscribe`, `wake.unsubscribe`, `wake.publish`, `wake.send`, `wake.topicStatus`, `wake.subscriptionStatus`, `wake.publishUrl(topic)`, `wake.closeTopic`, `wake.endSubscription`, `wake.unsubscribeUser`; `TopicSubscription`, `TopicEvent`, `TopicStatus`, `SubscriptionStatus`, `SendStatus`, `SubscribeOptions` and `Published` (its `matched` and `duplicate` are on `Emitted`). `PublishUrl.topic` goes with them.
121
+ - `wake.args`: `attached.inputs(shape)`.
122
+ - `wake.call`, `wake.callOf`, `Call`, `CallOptions`, `ClientInfoSource`, `applyAddition`, `wake.addition`, `wake.annotate` and `Addition`: the wrapper.
123
+ - `UNKNOWN_CLIENT_WARNING`, which only the removed calls gave. A wrapped tool's `call.recognised` says what Wakeshift recognized the agent as.
124
+ - `AgentScope.groups`.
125
+ - `AppKey.mode` and `wake.mode`, which meant nothing since current keys came in. Older keys (`wst_test_…`, `wst_live_…`) still work and name their app; a `wst_test_` key still needs a core.
126
+ - `session` as a hidden alias of `session_key`: a wrapped tool takes `session_key` only, and a `session` argument is dropped like any other the tool doesn't take.
127
+ - Reading a client's top-level `events` capability, the earlier MCP Events sketch's key: a client speaks MCP Events if it declares the extension (`extensions["io.modelcontextprotocol/events"]`) or is ChatGPT. `attach` still advertises both keys, since ChatGPT reads the top-level one.
128
+ - `speaksEvents`: a wrapped tool's `call.native` says whether its caller subscribes to your events itself.
129
+ - `LEGACY_KEY`, the top-level capability key `attach` advertises for ChatGPT. It's no longer exported; `EXTENSION_KEY` is the one a client declares.
130
+
131
+ Kept: `defineEvent`, `emit`, `event.publishUrl`, `event.status` and `event.statusOf`, `event.end`, `attach` with its three tools and native MCP Events, `revokePublishUrl`, `forgetUser`, `publishManifest`, `workspace`, `addAgent`, `checkAgent`, `harnessAgent`, `SESSION_ARG`, `HARNESS_ARG`, `CONVERSATION_ARG`, keys and errors.
132
+
133
+ ### Added
134
+
135
+ - `wake.attach` returns a handle, `Attached`. Wrap your own tool's callback with `attached.events("name", handler)` and make its input schema with `attached.inputs(shape)` (a type error without it): the handler gets the tool's own arguments and a `ToolCall` (`user`, `tool`, `native`, `nativeConversation`, `session`, `recognised`, `extra`), subscribes the calling conversation with `await call.subscribe(event, args, { ttl?, reason?, label? })` (a `Subscription`: `{ id, user, created, session, recognised, native }`) and ends it with `call.unsubscribe`. A `ToolCall` used after its handler returned throws `CallEnded`.
136
+ - `call.resolveSession()` finds or starts the conversation's session without subscribing it. `call.subscribe`'s `reason` is shown in the console.
137
+ - `identify` can return the MCP client's name, `{ user, client }`, for a stateless server that never sees `initialize`: Wakeshift reads it to tell which program the agent runs in, and whether the client subscribes to events itself. A name the request carries (`initialize`'s, or its `_meta`'s) wins. You no longer need to patch `server.server.getClientVersion` for it.
138
+ - `attach`'s `tools` option names which of its own tools it adds (`BUILTIN_TOOLS`: `subscribe_events`, `unsubscribe_events`, `list_subscriptions`). All three by default; `[]` adds none.
139
+ - `attached.output(shape)` adds an optional `wakeshift` string to an output schema, which the wrapper fills with the block's text, for hosts that show only `structuredContent`.
140
+ - A personal event's `emit` takes many recipients: `to` is a user, a `Recipient` (`{ user, subscription }`, exactly that subscription; `{ native: user }`, only that user's own MCP Events subscriptions) or a list of them, mixed, up to 1,000. Its result, `Emitted`, adds `unmatched` and `ended`. `publishUrl` still takes exactly one user.
141
+ - `event.status(args, { to? })`: how many conversations are subscribed to an event with these arguments (a personal event: one user's), how many are connected now, and how. `event.statusOf([{ user, subscription }, …])` reads up to 1,000 subscriptions by ID: whether each is live and its conversation connected.
142
+ - `AppInfo.key`: the key's console ID and name, which `wake.app()` returns, as the Go and Python SDKs do.
143
+ - `Manifest.preapprove_tools`: your tools, by name, that agents' programs can run without asking each time (at most 20). When a user connects Claude Code or Cursor CLI, Wakeshift offers to allow exactly these in the same question as its install.
144
+ - `Manifest.others_can_wake`: your app wakes its users' agents with what other people and their agents do there, so the consent screen says agents of people they don't know can wake theirs.
145
+
146
+ ### Changed
147
+
148
+ - A wrapped tool's reply carries one Wakeshift block, however many subscriptions the call makes, and only what the conversation needs.
149
+ - A subscription a failed tool call made is ended: a call that throws, returns `isError`, returns output its `outputSchema` refuses, or is canceled.
150
+ - A client that speaks MCP Events is told which event to subscribe to, or that it has: `call.native` is true, and `call.subscribe` makes nothing. The SDK recognizes ChatGPT's `_meta["openai/session"]`, its client name or User-Agent, and clients that declare MCP Events, on stateless servers too; `attach`'s `events.native(extra)` names callers it can't recognize, such as your own agents.
151
+ - `session_key` is the one name for the session argument.
152
+ - `attach`'s own tools (`subscribe_events`, `unsubscribe_events`, `list_subscriptions`) describe `session_key` to the agent as a wrapped tool does.
153
+ - `list_subscriptions` is marked read-only and idempotent (`readOnlyHint: true`, `idempotentHint: true`), so clients that run read-only tools without asking (Codex, by default) don't ask before it.
154
+ - You needn't call `publishManifest`. Wakeshift gets the manifest automatically the first time your app needs it (an emit, a status, a publish URL, a subscription), and again after a `defineEvent`. The automatic send carries everything your app configured: the manifest you last passed to `publishManifest` (even if that call failed), or else the description, agent scope, `others_can_wake` and `preapprove_tools` Wakeshift already has. Before, an automatic send before your first `publishManifest` carried only the events, wiping the rest.
155
+ - An emit's `to` that's an empty string, or only whitespace, is refused (`bad_arguments`: a recipient's user is empty), as an empty user in a list is and as the Go and Python SDKs do. It was taken as no recipient.
156
+
157
+ ### Fixed
158
+
159
+ - `statusOf` trims each subscription ID, as it does each user, and refuses one that's only whitespace (`bad_request`): every SDK trims the whitespace around recipients' names.
160
+ - A successful answer that is a JSON array is a `bad_response` error, as it is in the Go and Python SDKs.
161
+ - An error whose `code` isn't a string has an empty code, and one whose `message` is empty takes the body's start, else the status's text.
162
+ - An MCP Events client gets "Wakeshift couldn't be reached" when the core is unreachable, without the core's address; a `callback_failed` error's `data.reason` is always a string.
163
+ - Google's 500 page is recognized by "Server Error" within the body's first 4 KB.
164
+ - A failed tool call's rollback no longer ends a subscription that another call on the same conversation renewed in the meantime (two calls in parallel, or a second call while the first was still running). The call that renewed it told the agent it would be woken; now it is.
165
+ - `defineEvent` no longer adds the default route's filter to the `filter` object you passed: it copies it.
166
+ - A personal event's `emit` without `to` says to pass the subscribers it's for, not one.
167
+
3
168
  ## 0.3.0 (2026-10-26)
4
169
 
5
170
  ### Breaking
package/README.md CHANGED
@@ -14,7 +14,7 @@ Install the package, not a checkout of this repository (a `file:` dependency): a
14
14
 
15
15
  ## Quickstart: a CI bot
16
16
 
17
- You have an MCP server and a GitHub webhook handler. Register an app in the Wakeshift console (Build), and put its key in `WAKESHIFT_KEY` (`wst_` and 50 characters). The SDK talks to `https://app.wakeshift.ai` unless `WAKESHIFT_CORE` names another address. Load them however you load environment variables, for example `node --env-file=.env server.ts`.
17
+ You have an MCP server and a GitHub webhook handler. Register an app in the Wakeshift console (Build), and put its key in `WAKESHIFT_KEY` (`wst_` and 50 characters). The SDK talks to `https://app.wakeshift.ai` unless `WAKESHIFT_CORE` names another address. Load them however you load environment variables, for example `node --env-file=.env server.ts` (Node 22.18 or later runs a `.ts` file directly).
18
18
 
19
19
  ```ts
20
20
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
@@ -27,7 +27,7 @@ const ciFinished = wake.defineEvent("ci.finished", {
27
27
  description: "CI finished on a GitHub repo",
28
28
  args: z.object({ owner: z.string(), repo: z.string(), branch: z.string().optional() }),
29
29
  payload: z.object({ branch: z.string(), conclusion: z.string(), name: z.string(), url: z.string() }),
30
- authorize: (subscriber, { owner, repo }) => canSeeRepo(subscriber, `${owner}/${repo}`),
30
+ authorize: (subscriber, { owner, repo }) => canSeeRepo(subscriber.user, `${owner}/${repo}`),
31
31
  text: (run, { owner, repo }) => `CI ${run.conclusion} on ${owner}/${repo} ${run.branch}: ${run.name}`,
32
32
  })
33
33
 
@@ -53,7 +53,7 @@ if (req.headers["x-github-event"] === "workflow_run" && body.action === "complet
53
53
  That's all. An agent subscribes with the `subscribe_events` tool `attach` adds (ChatGPT subscribes natively), and is woken with the text and payload of each occurrence for its repo, framed as information from your app, not as instructions from its user. A subscriber that names a `branch` only gets that branch's occurrences.
54
54
 
55
55
  - `owner` and `repo` are required, so they route occurrences; their values are lowercase IDs (`a-z 0-9 . _ -`). The optional `branch` filters on the payload's `branch`.
56
- - `attach` adds `subscribe_events`, `unsubscribe_events` and `list_subscriptions`, and touches no other tool or result.
56
+ - `attach` adds `subscribe_events`, `unsubscribe_events` and `list_subscriptions`, and touches no tool of yours you don't wrap (below).
57
57
  - Native subscriptions (ChatGPT) and personal events need a signed-in user: your app signs its users in and passes `identify` to `attach`.
58
58
 
59
59
  ## Events
@@ -84,87 +84,113 @@ const turn = wake.defineEvent("game.turn", {
84
84
  personal: true,
85
85
  })
86
86
 
87
- // One server per connection; attach before connecting it.
87
+ // One server per connection; attach once, before connecting it.
88
88
  const server = new McpServer({ name: "chat", version: "1.0.0" })
89
89
  // myUser: your own sign-in's ID for the person behind the request.
90
- wake.attach(server, { identify: (extra) => ({ user: myUser(extra) }) })
90
+ const attached = wake.attach(server, { identify: (extra) => ({ user: myUser(extra) }) })
91
91
 
92
92
  // When something happens:
93
93
  await message.emit({ channel: "eng" }, { from: "ada", text: "shipped it" })
94
94
  await turn.emit({ table: "juniper" }, { hole: ["As", "Kd"], toCall: 2 }, { to: "user-123" })
95
95
  ```
96
96
 
97
- - **`wake.defineEvent(name, { description, args, payload, text, authorize?, personal? })`** returns the event. `args` is what a subscriber picks; its required string, integer and boolean fields route occurrences, so their values are lowercase IDs (a-z 0-9 . _ -), and an optional one filters on the payload field of the same name. `authorize(subscriber, args)` decides who may subscribe. Define every event before `attach`.
98
- - **`event.emit(args, payload, { id?, to? })`** checks the payload against its schema and sends it to everyone subscribed with those arguments; a personal event goes to the one subscriber `to` names (the `user` attach's `identify` gives for them). `id` makes retries emit once.
99
- - **`wake.attach(server, { identify? })`** attaches Wakeshift to your MCP server. Today it serves `events/list`, `events/subscribe` and `events/unsubscribe` to clients that speak MCP Events, and adds `subscribe_events` (for everyone else), `unsubscribe_events` and `list_subscriptions` (for every client, so a person can always see and stop what they're subscribed to). `identify(extra)` says who is behind a request, as `{ user }`, your app's ID for the person, from your own sign-in; without it, requests have no user, so personal events and `list_subscriptions` refuse. The events go into your manifest on first use; `publishManifest` adds them too.
100
- - **`event.subscribe(extra, args, { server or client, tool?, user?, wakeArgs? })`** subscribes the conversation calling one of your own tools, the way `subscribe_events` would, so a tool like "join" can set up its wakes in one step; pass `tool`, your tool's name, for wake-ups to connect (see `wake.call`); it gives back the subscription's `id`, the conversation's `session` (its `session_key`) and `result(text)`, your tool's text with what the agent needs added. Your tool decides who may (`authorize` isn't consulted). Clients that speak MCP Events subscribe themselves. **`event.unsubscribe(extra, args, …)`** ends the calling conversation's subscription with those arguments (for a tool like "unwatch"); **`event.end(user, id)`** ends one of a user's subscriptions by its ID.
97
+ - **`wake.defineEvent(name, { description, args, payload, text, authorize?, personal?, deleteOnDelivery? })`** returns the event. `args` is what a subscriber picks; its required string, integer and boolean fields route occurrences, so their values are lowercase IDs (a-z 0-9 . _ -), and an optional one filters on the payload field of the same name. `authorize(subscriber, args)` decides who may subscribe. With `deleteOnDelivery: true`, Wakeshift deletes each occurrence's text and payload once it's delivered. Define every event before `attach`, which throws with none.
98
+ - **`event.emit(args, payload, { id?, to? })`** checks the payload against its schema and sends it to everyone subscribed with those arguments. A personal event goes to its recipients, `to`: users (the `user` attach's `identify` gives for them), `{ user, subscription }` (exactly one conversation's subscription, the `id` `call.subscribe` returned) and `{ native: user }` (only that user's own MCP Events subscriptions), mixable, up to 1,000. It returns `{ matched, duplicate, unmatched, ended }`: `matched` counts the conversations woken, `unmatched` the users with nothing subscribed, `ended` the subscriptions no longer live (forget them). `id` makes retries emit once; a retry must name the same recipients.
99
+ - **`wake.attach(server, { identify?, tools?, events? })`** attaches Wakeshift to your MCP server and returns the handle your own tools use (below). It serves `events/list`, `events/subscribe` and `events/unsubscribe` to clients that speak MCP Events, and adds `subscribe_events` (for everyone else), `unsubscribe_events` and `list_subscriptions` (for every client, so a person can always see and stop what they're subscribed to); `tools` names fewer of them (`BUILTIN_TOOLS`; `[]` for none). `identify(extra)` says who is behind a request, as `{ user, client? }`: `user` is your app's ID for the person, from your own sign-in; without it, requests have no user, so personal events and `list_subscriptions` refuse. On a stateless server, which never sees `initialize`, `client` is the MCP client's name as your sign-in knows it (an OAuth registration's `client_name`, say): Wakeshift reads it to tell which program the agent runs in. A name the request carries wins. It also sends your app's manifest, with your events, the first time they're needed: it adds them and never removes one. A separate process that only emits never sends it.
100
+ - **`event.end(user, id)`** ends one of a user's subscriptions by its ID, outside a tool (a player who timed out, a member removed).
101
+ - **`event.status(args, { to? })`** counts the conversations subscribed with those arguments (a personal event: one user's), how many are connected now, and how: `{ subscriptions, connected, via: { stream, poll, hooks, webhook } }`. **`event.statusOf([{ user, subscription }, …])`** reads up to 1,000 subscriptions by ID: `[{ id, user, live, connected, via? }]`. Both only read.
102
+ - **`event.publishUrl(args, { to? })`** returns `{ id, event, url }`, a capability URL that emits the event with those arguments (a personal event: to one user) for services that only send webhooks. A POST's JSON body is the payload, checked against the event's schema. `wake.revokePublishUrl(id)` stops it.
101
103
 
102
- ## Reference
104
+ ### Subscribing from your own tools
103
105
 
104
- ### `new Wakeshift({ key, core?, fetch?, timeoutMs?, onWarning? })`
106
+ A tool like "join" subscribes the conversation calling it: wrap its callback with the handle `attach` returns, and call `call.subscribe`. Wakeshift adds what the agent needs to be woken to whatever the tool returns, once per call however many subscriptions it makes, and ends the subscriptions of a call that fails.
105
107
 
106
- - `key`: `wst_` and 50 characters. It's your app's Ed25519 private key; it never goes over the wire. Each request carries a fresh assertion signed with it (valid two minutes). Older keys, `wst_test_<app>_<seed>` and `wst_live_<app>_<seed>`, keep working.
107
- - `core`: the address of the console the key came from. Default: `WAKESHIFT_CORE` from the environment, else `https://app.wakeshift.ai`. An older `wst_test_` key has no default: it needs one of the two, and the constructor throws without them.
108
- - `onWarning(message)`: called once per kind of problem that doesn't fail a call, such as a tool call Wakeshift recognised no harness in. Default: `console.warn`.
109
- - `await wake.app()`: `{ id, name }`, which app the key is, as Wakeshift says. It asks once and remembers the answer.
110
- - `wake.appId`: the app's ID. A current key doesn't name its app, so this throws until `await wake.app()` has answered; an older key names it. `wake.core`: the address in use. `wake.mode` is deprecated: `"test"` or `"live"` for an older key, `null` for a current one.
108
+ ```ts
109
+ // attached: what wake.attach(server, …) returned, above.
110
+ server.registerTool(
111
+ "join",
112
+ { description: "Take a seat at a table", inputSchema: attached.inputs({ table: z.string() }) },
113
+ attached.events("join", async ({ table }, call) => {
114
+ const sub = await call.subscribe(turn, { table }, { reason: `Playing at ${table}` })
115
+ // Keep sub.user and sub.id to reach this conversation later:
116
+ // turn.emit({ table }, hand, { to: [{ user: sub.user, subscription: sub.id }] })
117
+ return `You're seated at ${table}.`
118
+ }),
119
+ )
120
+ ```
111
121
 
112
- ### The caller
122
+ - **`attached.inputs(shape)`** is `shape` plus Wakeshift's three optional arguments, `session_key`, `harness` and `conversation_id`. A tool wrapped with `attached.events` needs them: without them it doesn't compile. An argument a tool doesn't declare is ignored, not refused.
123
+ - **`attached.events(name, handler)`** wraps the callback of the tool registered as `name` (the MCP SDK doesn't tell a callback its tool's name; the first call checks it). The handler gets the tool's own arguments and a `call`, and returns a string or a `CallToolResult`.
124
+ - **`call`**: `user` (from `identify`), `tool`, `native` and `nativeConversation`, `session` (the `session_key` passed; after a subscribe, the conversation's session), `recognized` (which program Wakeshift recognized the agent as, `null` before a subscribe) and `extra` (MCP's own). It works until the handler returns.
125
+ - **`await call.subscribe(event, args, { ttl?, reason?, label? })`** subscribes the calling conversation, or renews its subscription, and resolves to `{ id, user, created, session, recognized, native }`. `reason` is shown in your console. Your tool decides who may (`authorize` isn't consulted). **`await call.unsubscribe(event, args)`** ends it; **`await call.resolveSession()`** finds or starts the conversation's session without subscribing it.
126
+ - **A failed call**: one that throws, returns `isError`, returns output its `outputSchema` refuses, or is canceled, ends every subscription it made, and its reply gets nothing from Wakeshift.
127
+ - **Clients that subscribe themselves** (MCP Events: ChatGPT and others) aren't subscribed: `call.native` is true, and the reply tells them which event to subscribe to. `attach`'s `events.native(extra)` tells the SDK about callers it can't recognize, such as your own agents.
128
+ - **Structured output**: some hosts show the model only `structuredContent`. Make the tool's `outputSchema` with `attached.output(shape)`, and its `wakeshift` field carries what Wakeshift adds, too.
113
129
 
114
- For your own tools that subscribe (`event.subscribe`) and for topics.
130
+ ```ts
131
+ server.registerTool(
132
+ "look",
133
+ {
134
+ description: "Look at a table",
135
+ inputSchema: attached.inputs({ table: z.string() }),
136
+ outputSchema: attached.output({ seats: z.number() }),
137
+ },
138
+ attached.events("look", async ({ table }) => ({
139
+ content: [{ type: "text", text: `3 seats at ${table}.` }],
140
+ structuredContent: { seats: 3 },
141
+ })),
142
+ )
143
+
144
+ server.registerTool(
145
+ "leave",
146
+ { description: "Leave a table", inputSchema: attached.inputs({ table: z.string() }) },
147
+ attached.events("leave", async ({ table }, call) => {
148
+ const left = await call.unsubscribe(turn, { table })
149
+ return left ? `You left ${table}.` : `You weren't at ${table}.`
150
+ }),
151
+ )
152
+ ```
115
153
 
154
+ A wrapped tool that subscribes to nothing, like `look`, still gets what Wakeshift has for the conversation calling it: occurrences held for an agent Wakeshift can't wake, or what it needs to reconnect. Usually that's nothing.
116
155
 
117
- - `wake.call(extra, { server, tool, user?, args?, session?, harness?, conversation?, client? })` packs a tool call from an MCP tool callback's `extra`: its `_meta` (Codex sends its thread there), the request's User-Agent (from `extra.requestInfo`), the client's name (from `server`'s client info; or pass `client` yourself, for example on a stateless server), `user` (your ID for the person; their conversations are grouped, and `u/<user>/…` topics are theirs), and `session_key`, `harness` and `conversation_id` from `args`. Pass `tool`, the name of the tool being called (the MCP SDK doesn't give callbacks it): the connector beacon Wakeshift adds to a result works only in the result of the tool it was made for, so wake-ups connect only when you pass it.
118
- - `wake.callOf({ meta, clientName, tool?, user?, session?, userAgent?, harness?, conversation? })` does the same without an MCP framework.
119
- - `wake.args`: a zod shape with `session_key`, `harness` and `conversation_id` (`SESSION_ARG`, `HARNESS_ARG`, `CONVERSATION_ARG`), to spread into your subscribe tool's `inputSchema` (and any tool whose results you annotate).
156
+ ## Reference
120
157
 
121
- ### Topics
158
+ ### `new Wakeshift({ key, core?, fetch?, timeoutMs?, onWarning? })`
122
159
 
123
- Topics are what events are built on; use them directly for publish URLs, `send` and a subscribe tool of your own with `once` or `replay`. Lowercase segments of `a-z 0-9 . _ -` joined by `/`, up to 200 characters, scoped to your app. `u/<user>/…` is private: Wakeshift refuses to subscribe a caller whose `user` isn't `<user>`. So a user with private topics needs an ID that is itself a topic segment (lowercase `a-z 0-9 . _ -`): use an opaque ID, or a hash of yours, not an email. Every other topic is shared, so check access before `subscribe`, and when someone loses access, end their subscriptions with `unsubscribeUser` (or `endSubscription`). Don't put secrets in topic names.
160
+ - `key`: `wst_` and 50 characters. It's your app's Ed25519 private key; it never goes over the wire. Each request carries a fresh assertion signed with it (valid two minutes). Older keys, `wst_test_<app>_<seed>` and `wst_live_<app>_<seed>`, keep working.
161
+ - `core`: the address of the console the key came from. Default: `WAKESHIFT_CORE` from the environment, else `https://app.wakeshift.ai`. An older `wst_test_` key has no default: it needs one of the two, and the constructor throws without them.
162
+ - `onWarning(message)`: called for problems that don't fail a call: each time for a failure, such as a failed call's subscription that couldn't be rolled back, and once for each kind of misuse, such as a wrapped tool that returned with a subscribe still in flight. Default: `console.warn`.
163
+ - `await wake.app()`: `{ id, name }`, which app the key is, as Wakeshift says. It asks once and remembers the answer.
164
+ - `wake.appId`: the app's ID. A current key doesn't name its app, so this throws until `await wake.app()` has answered; an older key names it. `wake.core`: the address in use.
124
165
 
125
- ### Calls
166
+ ### Users
126
167
 
127
- | Call | Returns |
128
- | --- | --- |
129
- | `subscribe(call, topic, { filter?, once?, replay?, ttl? })` | `{ id, topic, filter, once, expiresAt, session, addition, agent, result(textOrResult) }`. `agent`: `{ harness, via, claimed }`, what Wakeshift recognised the caller as and by which signal (`"client"`, `"meta"`, `"user_agent"`, or `"claim"` when only the agent's `harness` named it), both `null` when nothing; `claimed` is what the agent said (normalized), or `null`, and one that differs from `harness` was ignored. `filter`: equality on top-level fields of the event's `data`. `once`: end after the first delivery. `replay`: deliver the latest event published within this long before subscribing (at most `"24h"`). `ttl`: end after this long without a delivery or a fresh subscribe (default `"30d"`). Durations are seconds or `"90s"`, `"5m"`, `"2h"`, `"30d"` (whole days). Subscribing again refreshes it. `result` returns your text (or `CallToolResult`) followed by Wakeshift's text, with its `_meta`. |
130
- | `unsubscribe(call, topic?)` | How many of the calling conversation's subscriptions ended (all your topics without `topic`). |
131
- | `unsubscribeUser(user, topic?)` | How many ended: every conversation of `user` stops getting `topic` (all your topics without it). Each conversation is told you ended it. For when someone loses access. |
132
- | `endSubscription(subscriptionId)` | `true` if it ended one subscription (its conversation is told you ended it); `false` if it had already ended or isn't yours. |
133
- | `publish(topics, { text, data?, id? })` | `{ matched, duplicate }`. `topics`: one or up to 20. `text` at most 1000 bytes; `data` a JSON object within the 64 KB request limit. The same `id` within a day publishes once (`duplicate: true`); after a day, it publishes again. |
134
- | `send(subscriptionId, event)` | `"delivered"` (connected, on its way), `"queued"` (waits for the conversation) or `"closed"` (the subscription ended). Ignores the filter. |
135
- | `topicStatus(topic)` | `{ topic, subscriptions, connected, via: { stream, poll, hooks, webhook } }`: how many conversations are subscribed, and how many are connected now and how (`stream`: a stream held open; `poll`: waiting or polling; `hooks`: Wakeshift's hooks or plugin on the user's machine; `webhook`: a client subscribed natively, such as ChatGPT). Only reads. |
136
- | `subscriptionStatus(subscriptionId)` | `{ id, topic, status: "active" \| "closed", connected, via?, expiresAt? }`. Only reads. |
137
- | `publishUrl(topic)` | `{ id, topic, url }`: anyone can POST to `url` to publish to `topic` (a JSON object body becomes `data`; `Idempotency-Key` or `X-GitHub-Delivery` dedupes). |
138
- | `revokePublishUrl(id)` | The URL answers 410 from then on. |
139
- | `annotate(call, result)` | A copy of `result` with what Wakeshift adds for this conversation (messages waiting for an agent that can't be woken, or what an agent needs to reconnect; usually nothing). Only results you pass here (and `sub.result`) change. `addition(call)` returns the raw `{ text?, meta?, agent }`. |
140
- | `closeTopic(topic)` | How many subscriptions ended. Each conversation gets what was already published to it, then a notice. |
141
- | `forgetUser(user)` | `false` if there was nothing to forget. Deletes everything Wakeshift keeps for the user: their conversations, subscriptions, events and deliveries, stream links (open streams close), recent publishes to their `u/<user>/…` topics and publish URLs for them, and their ID. Your app's log records that a user was forgotten, without their ID or anything derived from it. Their subscription IDs stay known as closed. |
168
+ - `wake.forgetUser(user)`: `false` if there was nothing to forget. Deletes everything Wakeshift keeps for the user: their conversations, subscriptions, events and deliveries, stream links (open streams close), publish URLs for them, and their ID. Your app's log records that a user was forgotten, without their ID or anything derived from it.
142
169
 
143
170
  ### Manifest and workspaces
144
171
 
145
- - `wake.publishManifest({ description?, agent?, events? })`: what your app may do; returns it as Wakeshift keeps it. `agent`: `{ wake, post, ttl_days, wake_limits?, start_prompt? }`, each scope event `{ pattern, description }`: what the agents in the workspaces your app keeps may be woken by and post.
172
+ - `wake.publishManifest({ description?, agent?, events?, others_can_wake?, preapprove_tools? })`: what your app may do; returns it as Wakeshift keeps it. `agent`: `{ wake, post, ttl_days, wake_limits?, start_prompt? }`, each scope event `{ pattern, description }`: what the agents in the workspaces your app keeps may be woken by and post. `others_can_wake`: other people and their agents wake your users' agents through your app (messages, moves), so the consent screen says so. `preapprove_tools`: your tools, by name, that agents' programs can run without asking each time (at most 20: tools that only read, and routine ones that reach only what the agent joined); when a user connects Claude Code or Cursor CLI, Wakeshift offers to allow exactly these. It replaces the manifest, so it's the way to remove an event. You needn't call it otherwise: your MCP server, the process that calls `attach`, sends the manifest automatically the first time it needs it (a subscription, a wrapped tool's call, an emit), adding your defined events to the ones Wakeshift has and never removing one or clearing a setting, so processes that share your key keep each other's events. A process that only emits never sends it. Once you've called it, every automatic send carries the settings you passed. `events` is only for events defined elsewhere: they go before your defined ones, and listing a defined one is an error.
146
173
  - Your app identifies its own users with its own sign-in, and acts for the agents in the workspaces it keeps.
147
- - `wake.workspace(user, name)`, `wake.addAgent(workspace, agent, displayName)`, `wake.checkAgent(workspace, agent)`: the workspace Wakeshift keeps for one of your users, an agent in it with a grant for your manifest's agent scope, and whether that grant is live. Topics keep a user's conversations in that workspace; you don't need these for topics.
174
+ - `wake.workspace(user, name)`, `wake.addAgent(workspace, agent, displayName)`, `wake.checkAgent(workspace, agent)`: the workspace Wakeshift keeps for one of your users, an agent in it with a grant for your manifest's agent scope, and whether that grant is live. Wakeshift keeps a user's conversations in that workspace; you don't need these to subscribe them.
148
175
  - `harnessAgent(clientName)`: the agent ID a client name maps to (`"Claude Code"` is `claude-code`), as Wakeshift names it.
149
176
 
150
177
  ### Errors
151
178
 
152
- API errors throw `WakeshiftError` with `status` and `code`, the same codes as the [HTTP API](https://wakeshift.ai/docs/http-api)'s, plus `unreachable`:
179
+ API errors throw `WakeshiftError` with `status` and `code`: the same codes as the [HTTP API](https://wakeshift.ai/docs/http-api)'s, and `unreachable`. The SDK checks some mistakes before sending (arguments and payloads against the event's schemas, recipients, another app's event) and throws the error Wakeshift gives for the same mistake, so the code is the same whichever caught it, and the same in the Go and Python SDKs:
153
180
 
154
181
  | Status | Code | When |
155
182
  | --- | --- | --- |
156
- | `400` | `bad_request` | Something in the request is invalid, including a field the API doesn't take. The message says what. |
183
+ | `400` | `bad_request` | Something in the request is invalid, including a field the API doesn't take. The message says what. Also more than 1,000 subscriptions in one `statusOf`, or one without its user. |
184
+ | `400` | `bad_arguments` | An event's arguments or payload don't match its schemas, a routing value isn't a lowercase ID, or the recipients don't fit the event: a personal event's emit, publish URL or status without `to` (a publish URL's or status's names exactly one user), a shared event's with one, a recipient with an empty user or subscription, or `call.subscribe` on a personal event with no user. |
157
185
  | `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. |
158
- | `400` | `bad_arguments` | An event's arguments don't match its schema, or a routing value isn't a lowercase ID. |
159
- | `403` | `private_topic` | Subscribing a caller to another user's `u/<user>/…` topic. |
160
- | `403` | `other_client` | The `session` key is a conversation in another MCP client of the same user. Call again without it. |
161
- | `404` | `unknown_subscription` | The subscription ID isn't one of your app's. |
186
+ | `403` | `other_client` | The `session_key` is a conversation in another MCP client of the same user. Call again without it. |
187
+ | `404` | `unknown_event` | Your app offers no event by that name, or `call.subscribe` or `call.unsubscribe` was given another app's event (one defined on another `Wakeshift` client). |
162
188
  | `404` | `unknown_publish_url` | The publish URL ID isn't one of your app's. |
163
- | `404` | `unknown_event` | Your app offers no event by that name. |
164
- | `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. |
189
+ | `413` | `too_large` | The request body is over 64 KB (512 KB for an emit or a status call, room for 1,000 recipients). The limit is on the whole body, so the payload must leave room for the rest of the request. |
190
+ | `429` | `rate_limited` | Your emits to lists named over 10,000 recipients in a minute. |
165
191
  | `0` | `unreachable` | No answer at all: a wrong `core`, a network problem, or the timeout. The message names the core it tried. |
166
192
 
167
- `isCode(err, "private_topic")` checks one.
193
+ `isCode(err, "other_client")` checks one.
168
194
 
169
195
  ## Documentation
170
196
 
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * An error response from Wakeshift: `code` is the API's error code (e.g.
3
- * "private_topic", "other_client", "unknown_subscription", "bad_request"),
3
+ * "unknown_event", "other_client", "bad_arguments", "bad_request"),
4
4
  * `status` the HTTP status. A request that got no answer
5
5
  * has code "unreachable" and status 0.
6
6
  */
package/dist/errors.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * An error response from Wakeshift: `code` is the API's error code (e.g.
3
- * "private_topic", "other_client", "unknown_subscription", "bad_request"),
3
+ * "unknown_event", "other_client", "bad_arguments", "bad_request"),
4
4
  * `status` the HTTP status. A request that got no answer
5
5
  * has code "unreachable" and status 0.
6
6
  */
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,OAAO,cAAe,SAAQ,KAAK;IAC9B,MAAM,CAAS;IACf,IAAI,CAAS;IACtB,mDAAmD;IAC1C,MAAM,CAAS;IACxB,wEAAwE;IAC/D,MAAM,CAAU;IAEzB,YAAY,MAAc,EAAE,IAAY,EAAE,OAAe,EAAE,OAA8C;QACvG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,cAAc,IAAI,IAAI,OAAO,KAAK,MAAM,MAAM,OAAO,EAAE,CAAC,CAAC,CAAC,cAAc,OAAO,EAAE,EAAE,OAAO,CAAC,CAAC;QAC3G,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;QAC7B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC;QACtB,IAAI,OAAO,EAAE,MAAM;YAAE,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IACpD,CAAC;CACF;AAED,iDAAiD;AACjD,MAAM,UAAU,MAAM,CAAC,GAAY,EAAE,IAAY;IAC/C,OAAO,GAAG,YAAY,cAAc,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC;AAC5D,CAAC","sourcesContent":["/**\n * An error response from Wakeshift: `code` is the API's error code (e.g.\n * \"private_topic\", \"other_client\", \"unknown_subscription\", \"bad_request\"),\n * `status` the HTTP status. A request that got no answer\n * has code \"unreachable\" and status 0.\n */\nexport class WakeshiftError extends Error {\n readonly status: number;\n readonly code: string;\n /** Wakeshift's own message, without the prefix. */\n readonly detail: string;\n /** Why, where the API says (callback_failed: what the callback did). */\n readonly reason?: string;\n\n constructor(status: number, code: string, message: string, options?: { cause?: unknown; reason?: string }) {\n super(status ? `wakeshift: ${code || \"error\"} (${status}): ${message}` : `wakeshift: ${message}`, options);\n this.name = \"WakeshiftError\";\n this.status = status;\n this.code = code;\n this.detail = message;\n if (options?.reason) this.reason = options.reason;\n }\n}\n\n/** Whether err is a WakeshiftError with code. */\nexport function isCode(err: unknown, code: string): boolean {\n return err instanceof WakeshiftError && err.code === code;\n}\n"]}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,OAAO,cAAe,SAAQ,KAAK;IAC9B,MAAM,CAAS;IACf,IAAI,CAAS;IACtB,mDAAmD;IAC1C,MAAM,CAAS;IACxB,wEAAwE;IAC/D,MAAM,CAAU;IAEzB,YAAY,MAAc,EAAE,IAAY,EAAE,OAAe,EAAE,OAA8C;QACvG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,cAAc,IAAI,IAAI,OAAO,KAAK,MAAM,MAAM,OAAO,EAAE,CAAC,CAAC,CAAC,cAAc,OAAO,EAAE,EAAE,OAAO,CAAC,CAAC;QAC3G,IAAI,CAAC,IAAI,GAAG,gBAAgB,CAAC;QAC7B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC;QACtB,IAAI,OAAO,EAAE,MAAM;YAAE,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IACpD,CAAC;CACF;AAED,iDAAiD;AACjD,MAAM,UAAU,MAAM,CAAC,GAAY,EAAE,IAAY;IAC/C,OAAO,GAAG,YAAY,cAAc,IAAI,GAAG,CAAC,IAAI,KAAK,IAAI,CAAC;AAC5D,CAAC","sourcesContent":["/**\n * An error response from Wakeshift: `code` is the API's error code (e.g.\n * \"unknown_event\", \"other_client\", \"bad_arguments\", \"bad_request\"),\n * `status` the HTTP status. A request that got no answer\n * has code \"unreachable\" and status 0.\n */\nexport class WakeshiftError extends Error {\n readonly status: number;\n readonly code: string;\n /** Wakeshift's own message, without the prefix. */\n readonly detail: string;\n /** Why, where the API says (callback_failed: what the callback did). */\n readonly reason?: string;\n\n constructor(status: number, code: string, message: string, options?: { cause?: unknown; reason?: string }) {\n super(status ? `wakeshift: ${code || \"error\"} (${status}): ${message}` : `wakeshift: ${message}`, options);\n this.name = \"WakeshiftError\";\n this.status = status;\n this.code = code;\n this.detail = message;\n if (options?.reason) this.reason = options.reason;\n }\n}\n\n/** Whether err is a WakeshiftError with code. */\nexport function isCode(err: unknown, code: string): boolean {\n return err instanceof WakeshiftError && err.code === code;\n}\n"]}