@wakeshift/sdk 0.8.0 → 0.10.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
@@ -2,6 +2,37 @@
2
2
 
3
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
4
 
5
+ ## 0.10.0 (2026-11-03)
6
+
7
+ ### Changed
8
+
9
+ - **`wakeshift_wait` is opt-in.** `attach` (and `attachMastra`) no longer serves Claude Code's wait tool unless you turn it on: `wake.attach(server, { tools: { wait: { enabled: true } } })`. While a conversation waits on it, Claude Code keeps a request open to your server for about 20 seconds at a time and sends the next about a second later, for as long as it waits, whether or not anything arrives: on a serverless host, a function invocation per waiting conversation, all the time. This changes behavior for apps on today's defaults: a recent Claude Code in the terminal whose user declines Wakeshift's plugin now waits with a background monitor, as every other Claude Code does, rather than on your server. In manual permission mode that means approving the monitor, and each restart every 25 minutes, instead of one approval of `wakeshift_wait`; and the wait no longer carries on through `claude --continue`. Users who install the plugin see no difference. To keep the wait tool, add `tools: { wait: { enabled: true } }`, on a stateless server, and on a serverless host give the function a timeout of at least 30 seconds. Without it, `server/discover` no longer offers the tasks extension, tools/list no longer lists `wakeshift_wait`, and Wakeshift's `tasks/get` and `tasks/cancel` go on to your MCP SDK.
10
+ - **`tools` takes an object, each tool `{ enabled }`**: `subscribe`, `list` and `unsubscribe` (on unless disabled, as before) and `wait` (off unless enabled). `tools: { subscribe: { enabled: false } }` replaces `tools: ["unsubscribe_events", "list_subscriptions"]`. New types: `AttachTools`, `ToolOptions`, `WaitToolOptions`.
11
+
12
+ ### Deprecated
13
+
14
+ - **`tools` as a list of names** (`BUILTIN_TOOLS`). It still works in this release, adding the tools it names and never the wait tool, and warns once through `onWarning`.
15
+
16
+ ## 0.9.0 (2026-11-03)
17
+
18
+ ### Added
19
+
20
+ - **`subscribes`: a tool declares the events it subscribes to.** `attached.events("watch_build", { subscribes: [buildFinished] }, handler)`. tools/list then adds a sentence to the tool's description, written from each event's own description and naming your app as the console has it: "This tool is how Shipyard notifies you in this conversation of this event: A build finished: it passed, failed or was canceled. Shipyard delivers its notifications through Wakeshift; the result may ask you to start a background listener and to offer the user a one-time install. Follow it." Several events read "of these events: …; …". Every tool that subscribes gets the sentence without writing it: agents set Wakeshift's part of a result aside as something the user didn't ask for unless the tool's description says the tool notifies through it. `call.subscribe` takes only the declared events (a type error otherwise), and a tool that declares `subscribes: []` has no `call.subscribe`. Mastra's adapter takes the same option; xmcp's does too, with `attached.describe(description, { subscribes })` for the tool file's metadata, since xmcp writes tools/list itself. The Go SDK has `appkit.Subscribes`, the Python SDK `@attached.tool(subscribes=[…])`. Write event descriptions as plain statements of what happened, since agents now read them in your tools' descriptions.
21
+ - **`IdReusedError`: an emit's `id` used for a different emit is refused.** Wakeshift deduplicated emits on `id` alone, so reusing an `id` by accident (a timestamp or a commit SHA two occurrences share) for another event, other arguments, recipients or payload dropped the second emit silently, and it returned the first's `matched`, reading as delivered. Now Wakeshift compares what each emit sends: a retry of the same emit with its `id` still sends nothing new and returns the first's result with `duplicate: true`, and the same `id` on a different emit is refused with `409 id_reused` and sends nothing. `emit` throws `IdReusedError` for it, a `WakeshiftError` with code `id_reused` (`isCode(err, "id_reused")` works too). Recipients compare as a set, so a retry may list them in another order, and `"u1"` and `["u1"]` are the same.
22
+
23
+ ### Deprecated
24
+
25
+ - **A subscribe the tool doesn't declare.** `call.subscribe` from a tool wrapped without `subscribes` (`attached.events(name, handler)`), or to an event its `subscribes` doesn't list, still works in this release, and warns once per tool through `onWarning`, naming the tool and the fix. From the next release it throws. The two-argument `attached.events(name, handler)` stays, for tools that don't subscribe.
26
+
27
+ ### Changed
28
+
29
+ - **`notifies` is an override.** Tools that declare `subscribes` get the sentence without it. Use `notifies(description, { when })` only for your own wording: a description that already has its sentence gets no second one.
30
+
31
+ ### Fixed
32
+
33
+ - **An emit to one user with nothing subscribed lists them in `unmatched`.** It returned `matched: 0` and an empty `unmatched`, so the recipient was in neither; an emit to several listed them correctly. Every recipient is now in `matched`'s count, `unmatched` or `ended`, whether `to` names one or many.
34
+ - **`matched` is described the same everywhere**: the conversations an emit reached, each woken now or as soon as its agent can take it (the README said "woken").
35
+
5
36
  ## 0.8.0 (2026-11-03)
6
37
 
7
38
  ### Added
package/README.md CHANGED
@@ -105,8 +105,8 @@ await turn.emit({ table: "juniper" }, { hole: ["As", "Kd"], toCall: 2 }, { to: "
105
105
  ```
106
106
 
107
107
  - **`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.
108
- - **`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.
109
- - **`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(ctx)` 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` (`createMcpHandler`'s is one), `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. On a stateless server it also serves `wakeshift_wait` to clients that take MCP tasks (a recent Claude Code in the terminal): Wakeshift tells those conversations to call it, and it wakes them when your events arrive, with nothing to install; other clients don't see it. Once it has run, the first call about events sends your app's manifest, with your events: it adds them and never removes one. An emit before `attach` has run (`createMcpHandler` runs it on the first request), or from a separate process that only emits, sends the manifest only if Wakeshift answers with `unknown_event`, then emits again (see [emitting before any agent connects](#emitting-before-any-agent-connects)).
108
+ - **`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 reached, `unmatched` the users with nothing subscribed, `ended` the subscriptions no longer live (forget them). `id` makes retries emit once: a retry of the same emit returns the first's result with `duplicate: true`, and the same `id` on a different emit (other arguments, recipients or payload) throws `IdReusedError` (`id_reused`) and sends nothing.
109
+ - **`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` picks them, each `{ enabled }`: `subscribe`, `list` and `unsubscribe` are on unless disabled (`tools: { subscribe: { enabled: false } }`); a list of names (`BUILTIN_TOOLS`) still works, but is deprecated. `identify(ctx)` 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` (`createMcpHandler`'s is one), `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. With `tools: { wait: { enabled: true } }`, on a stateless server, it also serves `wakeshift_wait` to clients that take MCP tasks (a recent Claude Code in the terminal): Wakeshift tells those conversations to call it, after the user declines Wakeshift's plugin, instead of starting a background monitor; other clients don't see it. It's off by default, because each waiting conversation then keeps a request open to your server (about 20 seconds at a time, the next a second later, for as long as it waits): on a serverless host, that's a function invocation per waiting conversation, which needs a timeout of at least 30 seconds. Once it has run, the first call about events sends your app's manifest, with your events: it adds them and never removes one. An emit before `attach` has run (`createMcpHandler` runs it on the first request), or from a separate process that only emits, sends the manifest only if Wakeshift answers with `unknown_event`, then emits again (see [emitting before any agent connects](#emitting-before-any-agent-connects)).
110
110
  - **`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).
111
111
  - **`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.
112
112
  - **`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.
@@ -119,8 +119,8 @@ A tool like "join" subscribes the conversation calling it: wrap its callback wit
119
119
  // attached: what wake.attach(server, …) returned, above.
120
120
  server.registerTool(
121
121
  "join",
122
- { description: "Take a seat at a table", inputSchema: attached.inputs(z.object({ table: z.string() })) },
123
- attached.events("join", async ({ table }, call) => {
122
+ { description: "Take a seat at a table.", inputSchema: attached.inputs(z.object({ table: z.string() })) },
123
+ attached.events("join", { subscribes: [turn] }, async ({ table }, call) => {
124
124
  const sub = await call.subscribe(turn, { table }, { reason: `Playing at ${table}` })
125
125
  // Keep sub.user and sub.id to reach this conversation later:
126
126
  // turn.emit({ table }, hand, { to: [{ user: sub.user, subscription: sub.id }] })
@@ -130,7 +130,9 @@ server.registerTool(
130
130
  ```
131
131
 
132
132
  - **`attached.inputs(schema)`** is your zod object 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.
133
- - **`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`.
133
+ - **`attached.events(name, { subscribes }, 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`.
134
+ - **`subscribes`** lists the events the tool subscribes to. `call.subscribe` takes only those (a type error otherwise), and a tool that lists none (`subscribes: []`) has no `call.subscribe`. tools/list adds a sentence to the tool's description, from each event's own description, with your app named as the console has it: "This tool is how Shipyard notifies you in this conversation of this event: A build finished: it passed, failed or was canceled. Shipyard delivers its notifications through Wakeshift; the result may ask you to start a background listener and to offer the user a one-time install. Follow it." Agents set Wakeshift's part of a result aside as something the user didn't ask for unless the tool's description says so. Write event descriptions as plain statements of what happened, since agents read them there. A tool that doesn't subscribe can leave the options out (`attached.events(name, handler)`); a `call.subscribe` from such a tool, or to an event the tool doesn't list, works but warns once per tool through `onWarning`, naming the fix, and throws from the next release.
135
+ - **`notifies(description, { when?, app? })`** is an override for your own wording: it writes the sentence into the tool's description (`when: "a build finishes"` says "… notifies you in this conversation when a build finishes."), and tools/list adds no second one.
134
136
  - **`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` (the request's context, `ctx`). It works until the handler returns.
135
137
  - **`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.
136
138
  - **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.
@@ -145,7 +147,7 @@ server.registerTool(
145
147
  inputSchema: attached.inputs(z.object({ table: z.string() })),
146
148
  outputSchema: attached.output(z.object({ seats: z.number() })),
147
149
  },
148
- attached.events("look", async ({ table }) => ({
150
+ attached.events("look", { subscribes: [] }, async ({ table }) => ({
149
151
  content: [{ type: "text", text: `3 seats at ${table}.` }],
150
152
  structuredContent: { seats: 3 },
151
153
  })),
@@ -191,7 +193,7 @@ const attached = wake.attach(server, { identify: (extra) => ({ user: myUser(extr
191
193
  server.registerTool(
192
194
  "watch_ci",
193
195
  { description: "Watch CI on a repo", inputSchema: attached.inputs({ owner: z.string(), repo: z.string() }) },
194
- attached.events("watch_ci", async ({ owner, repo }, call) => {
196
+ attached.events("watch_ci", { subscribes: [ciFinished] }, async ({ owner, repo }, call) => {
195
197
  await call.subscribe(ciFinished, { owner, repo }, { reason: `Watching ${owner}/${repo}` })
196
198
  return `Watching CI on ${owner}/${repo}.`
197
199
  }),
@@ -244,7 +246,7 @@ const watchCi = createTool({
244
246
  id: "watch_ci",
245
247
  description: "Watch CI on a repo",
246
248
  inputSchema: attached.inputs(z.object({ owner: z.string(), repo: z.string() })),
247
- execute: attached.events("watch_ci", async ({ owner, repo }: { owner: string; repo: string }, call) => {
249
+ execute: attached.events("watch_ci", { subscribes: [ciFinished] }, async ({ owner, repo }: { owner: string; repo: string }, call) => {
248
250
  await call.subscribe(ciFinished, { owner, repo }, { reason: `Watching ${owner}/${repo}` })
249
251
  return `Watching CI on ${owner}/${repo}.`
250
252
  }),
@@ -253,9 +255,9 @@ const watchCi = createTool({
253
255
  const server = attached.serve(new MCPServer({ id: "ci-bot", name: "CI bot", version: "1.0.0", tools: { watch_ci: watchCi } }))
254
256
  ```
255
257
 
256
- Serve it as Mastra's docs do (`server.startHTTP`, `server.startStdio`, or registered on your `Mastra` instance). It gets everything `attach` gives: the built-in tools, MCP Events for clients that speak it, Claude Code's `wakeshift_wait`, and Wakeshift's addition in your wrapped tools' results.
258
+ Serve it as Mastra's docs do (`server.startHTTP`, `server.startStdio`, or registered on your `Mastra` instance). It gets everything `attach` gives: the built-in tools, MCP Events for clients that speak it, Claude Code's `wakeshift_wait` (with `tools: { wait: { enabled: true } }`), and Wakeshift's addition in your wrapped tools' results.
257
259
 
258
- - `attached.events(name, handler)` wraps a tool's `execute` for the tool your `MCPServer` serves under `name` (its key in `tools`). `handler(args, call, context)` returns what a Mastra tool returns: a string, or your output. Run outside an MCP client's call (by an agent, a workflow or Studio), `call.subscribe` throws.
260
+ - `attached.events(name, { subscribes }, handler)` wraps a tool's `execute` for the tool your `MCPServer` serves under `name` (its key in `tools`); `subscribes` is as on `attach`'s handle. `handler(args, call, context)` returns what a Mastra tool returns: a string, or your output. Run outside an MCP client's call (by an agent, a workflow or Studio), `call.subscribe` throws.
259
261
  - Wrapped tools answer in one call: a Mastra task or a request for input from one ends what it subscribed.
260
262
  - Your own MCP Events (`MCPServer`'s `events`) keep working: `events/list` lists yours and Wakeshift's, and each request goes to whichever serves the event it names.
261
263
  - `attached.serve` checks your `@mastra/mcp` version and throws for one outside 2.7–2.x.
@@ -282,8 +284,9 @@ const attached = attachXmcp(wake, { identify: (extra) => ({ user: myUser(extra.a
282
284
 
283
285
  // src/tools/watch_ci.ts: export these three
284
286
  const schema = attached.inputs({ owner: z.string(), repo: z.string() })
285
- const metadata = { name: "watch_ci", description: "Watch CI on a repo" }
286
- const handler = attached.events("watch_ci", async ({ owner, repo }: { owner: string; repo: string }, call) => {
287
+ // xmcp writes tools/list itself: describe gives the metadata the sentence attach adds elsewhere.
288
+ const metadata = { name: "watch_ci", description: attached.describe("Watch CI on a repo.", { subscribes: [ciFinished] }) }
289
+ const handler = attached.events("watch_ci", { subscribes: [ciFinished] }, async ({ owner, repo }: { owner: string; repo: string }, call) => {
287
290
  await call.subscribe(ciFinished, { owner, repo }, { reason: `Watching ${owner}/${repo}` })
288
291
  return `Watching CI on ${owner}/${repo}.`
289
292
  })
@@ -332,6 +335,7 @@ API errors throw `WakeshiftError` with `status` and `code`: the same codes as th
332
335
  | `403` | `other_client` | The `session_key` is a conversation in another MCP client of the same user. Call again without it. |
333
336
  | `404` | `unknown_event` | Your app offers no event by that name (an emit gets this only once the SDK has sent your manifest and tried again), or `call.subscribe` or `call.unsubscribe` was given another app's event (one defined on another `Wakeshift` client). |
334
337
  | `404` | `unknown_publish_url` | The publish URL ID isn't one of your app's. |
338
+ | `409` | `id_reused` | An emit's `id` was used within the last day for a different emit (another event, arguments, recipients or payload). Nothing was sent. Give each occurrence its own `id`. |
335
339
  | `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. |
336
340
  | `429` | `rate_limited` | Your emits to lists named over 10,000 recipients in a minute. |
337
341
  | `0` | `unreachable` | No answer at all: a wrong `core`, a network problem, or the timeout. The message names the core it tried. |
package/dist/errors.d.ts CHANGED
@@ -16,5 +16,16 @@ export declare class WakeshiftError extends Error {
16
16
  reason?: string;
17
17
  });
18
18
  }
19
+ /**
20
+ * An emit's id was used within the last day for a different emit (another
21
+ * event, arguments, recipients or payload): a WakeshiftError with code
22
+ * id_reused (status 409). Nothing was sent. A retry of the same emit isn't
23
+ * refused: it returns the first emit's result with `duplicate: true`.
24
+ */
25
+ export declare class IdReusedError extends WakeshiftError {
26
+ constructor(message: string, options?: {
27
+ cause?: unknown;
28
+ });
29
+ }
19
30
  /** Whether err is a WakeshiftError with code. */
20
31
  export declare function isCode(err: unknown, code: string): boolean;
package/dist/errors.js CHANGED
@@ -21,6 +21,18 @@ export class WakeshiftError extends Error {
21
21
  this.reason = options.reason;
22
22
  }
23
23
  }
24
+ /**
25
+ * An emit's id was used within the last day for a different emit (another
26
+ * event, arguments, recipients or payload): a WakeshiftError with code
27
+ * id_reused (status 409). Nothing was sent. A retry of the same emit isn't
28
+ * refused: it returns the first emit's result with `duplicate: true`.
29
+ */
30
+ export class IdReusedError extends WakeshiftError {
31
+ constructor(message, options) {
32
+ super(409, "id_reused", message, options);
33
+ this.name = "IdReusedError";
34
+ }
35
+ }
24
36
  /** Whether err is a WakeshiftError with code. */
25
37
  export function isCode(err, code) {
26
38
  return err instanceof WakeshiftError && err.code === code;
package/dist/events.d.ts CHANGED
@@ -53,7 +53,12 @@ export interface EventOptions<A extends AnySchema, P extends AnySchema> {
53
53
  filter?: Record<string, string>;
54
54
  }
55
55
  export interface EmitOptions {
56
- /** Makes retries emit once for a day (at most 200 characters). A retry must name the same recipients: Wakeshift uses the first's. */
56
+ /**
57
+ * Makes retries emit once for a day (at most 200 characters): a retry of
58
+ * the same emit (arguments, recipients and payload) returns the first's
59
+ * result with `duplicate: true`. The id on a different emit throws
60
+ * IdReusedError (`id_reused`) and sends nothing: give each occurrence its own.
61
+ */
57
62
  id?: string;
58
63
  /**
59
64
  * A personal event's recipients (at most 1,000), one or a list, mixed: a
@@ -76,17 +81,17 @@ export interface EventTypeManifest {
76
81
  personal?: boolean;
77
82
  delete_on_delivery?: boolean;
78
83
  }
79
- /** One of the app's event types (wake.defineEvent). */
80
- export declare class WakeEvent<A extends AnySchema = AnySchema, P extends AnySchema = AnySchema> {
84
+ /** One of the app's event types (wake.defineEvent). N is its name, as defineEvent was given it. */
85
+ export declare class WakeEvent<A extends AnySchema = AnySchema, P extends AnySchema = AnySchema, N extends string = string> {
81
86
  private readonly wake;
82
- readonly name: string;
87
+ readonly name: N;
83
88
  readonly description: string;
84
89
  readonly title?: string;
85
90
  readonly personal: boolean;
86
91
  readonly deleteOnDelivery: boolean;
87
92
  private readonly opts;
88
93
  /** Use wake.defineEvent rather than this constructor. */
89
- constructor(wake: Wakeshift, name: string, opts: EventOptions<A, P>);
94
+ constructor(wake: Wakeshift, name: N, opts: EventOptions<A, P>);
90
95
  /**
91
96
  * Sends one occurrence: to everyone subscribed with these arguments, or,
92
97
  * for a personal event, to the recipients `to` names. The payload is
@@ -189,19 +194,53 @@ export interface AttachOptions<C = any> {
189
194
  */
190
195
  identify?(extra: C): Identity | undefined;
191
196
  /**
192
- * Which of attach's own tools it adds (BUILTIN_TOOLS): subscribe_events,
193
- * unsubscribe_events and list_subscriptions. Default: all three; `[]`
194
- * adds none. Leave out unsubscribe_events or list_subscriptions only if
195
- * your own tools let a person see and stop their subscriptions.
197
+ * Which of attach's own tools it adds, each `{ enabled }` (AttachTools):
198
+ * `subscribe` (subscribe_events), `list` (list_subscriptions) and
199
+ * `unsubscribe` (unsubscribe_events), on unless disabled, and `wait`
200
+ * (wakeshift_wait, Claude Code's wait tool), off unless enabled:
201
+ * `tools: { wait: { enabled: true } }`. Disable `list` or `unsubscribe`
202
+ * only if your own tools let a person see and stop their subscriptions.
203
+ *
204
+ * Deprecated: a list of names (BUILTIN_TOOLS) still works for now, adding
205
+ * only the tools it names, and never the wait tool.
196
206
  */
197
- tools?: readonly BuiltinTool[];
207
+ tools?: AttachTools | readonly BuiltinTool[];
198
208
  /** The event features' options: events.native overrides native detection for wrapped tools (attached.events). */
199
209
  events?: EventsOptions<C>;
200
210
  }
201
- /** The tools attach adds, unless AttachOptions.tools names fewer. */
211
+ /** The tools attach adds by default (deprecated as AttachOptions.tools' list form). */
202
212
  export declare const BUILTIN_TOOLS: readonly ["subscribe_events", "unsubscribe_events", "list_subscriptions"];
203
- /** One of attach's own tools. */
213
+ /** One of attach's own tools, by name (AttachOptions.tools' deprecated list form). */
204
214
  export type BuiltinTool = (typeof BUILTIN_TOOLS)[number];
215
+ /** One of attach's own tools' options (AttachTools). */
216
+ export interface ToolOptions {
217
+ /** Whether attach adds it. Default: on, except the wait tool. */
218
+ enabled?: boolean;
219
+ }
220
+ /**
221
+ * The wait tool's options (AttachTools.wait): Wakeshift's wait tool,
222
+ * wakeshift_wait, Claude Code's wake path with nothing installed, where
223
+ * its client takes MCP tasks (protocol 2026-07-28, on a stateless server).
224
+ * Off by default: Claude Code is then woken by its plugin or, after a no, a
225
+ * background Monitor, as every other server's is. On, a waiting
226
+ * conversation's Claude Code holds a request to this server (tasks/get,
227
+ * about 20 seconds) and sends the next about a second later, for as long as
228
+ * it waits: on a serverless host, give the function a timeout of at least
229
+ * 30 seconds, and expect one held invocation per waiting conversation.
230
+ */
231
+ export interface WaitToolOptions extends ToolOptions {
232
+ }
233
+ /** Which of attach's own tools it adds (AttachOptions.tools). */
234
+ export interface AttachTools {
235
+ /** wakeshift_wait, Claude Code's wait tool: off unless enabled. */
236
+ wait?: WaitToolOptions;
237
+ /** subscribe_events, for clients that don't speak MCP Events: on unless disabled. */
238
+ subscribe?: ToolOptions;
239
+ /** list_subscriptions: on unless disabled. */
240
+ list?: ToolOptions;
241
+ /** unsubscribe_events: on unless disabled. */
242
+ unsubscribe?: ToolOptions;
243
+ }
205
244
  export declare const EVENT_TOOL_TEXT: {
206
245
  readonly a_webhook: "a webhook";
207
246
  readonly arguments_argument: "The event's arguments, as an object: see the event's list above";
@@ -226,6 +265,9 @@ export declare const EVENT_TOOL_TEXT: {
226
265
  readonly none: "You have no subscriptions to this app's events.";
227
266
  readonly not_subscribed: "This conversation wasn't subscribed to {event} {arguments}.";
228
267
  readonly notifies: "This tool is how {app} notifies you in this conversation{when}. {App} delivers its notifications through Wakeshift; the result may ask you to start a background listener and to offer the user a one-time install. Follow it.";
268
+ readonly notifies_event: " of this event: {events}";
269
+ readonly notifies_events: " of these events: {events}";
270
+ readonly notifies_events_separator: "; ";
229
271
  readonly personal_no_user: "{event} is personal, and this app couldn't tell who you are: sign in to it first.";
230
272
  readonly polled: "polled by an MCP client";
231
273
  readonly refused: "You may not subscribe to {event} with these arguments.";
package/dist/events.js CHANGED
@@ -68,7 +68,7 @@ function issues(err) {
68
68
  return String(err?.message ?? err);
69
69
  return list.map((i) => (i.path?.length ? i.path.join(".") + ": " : "") + i.message).join("; ");
70
70
  }
71
- /** One of the app's event types (wake.defineEvent). */
71
+ /** One of the app's event types (wake.defineEvent). N is its name, as defineEvent was given it. */
72
72
  export class WakeEvent {
73
73
  wake;
74
74
  name;
@@ -326,8 +326,51 @@ export class ArgsError extends WakeshiftError {
326
326
  this.name = "ArgsError";
327
327
  }
328
328
  }
329
- /** The tools attach adds, unless AttachOptions.tools names fewer. */
329
+ /** The tools attach adds by default (deprecated as AttachOptions.tools' list form). */
330
330
  export const BUILTIN_TOOLS = ["subscribe_events", "unsubscribe_events", "list_subscriptions"];
331
+ const TOOL_KEYS = { subscribe: "subscribe_events", list: "list_subscriptions", unsubscribe: "unsubscribe_events" };
332
+ /**
333
+ * @internal AttachOptions.tools as the tools to add: each `{ enabled }` of
334
+ * the object form, or the names of the deprecated list form (never the
335
+ * wait tool). Throws on a key, name or value it doesn't know.
336
+ */
337
+ export function toolChoice(tools, what = "attach") {
338
+ if (tools === undefined)
339
+ return { builtin: new Set(BUILTIN_TOOLS), wait: false };
340
+ if (Array.isArray(tools)) {
341
+ for (const t of tools) {
342
+ if (!BUILTIN_TOOLS.includes(t))
343
+ throw new Error(`wakeshift: ${what}'s tools names "${t}": its own tools are ${BUILTIN_TOOLS.join(", ")}`);
344
+ }
345
+ return { builtin: new Set(tools), wait: false };
346
+ }
347
+ if (!tools || typeof tools !== "object")
348
+ throw new Error(`wakeshift: ${what}'s tools is { wait, subscribe, list, unsubscribe }, each { enabled }`);
349
+ const o = tools;
350
+ for (const [k, v] of Object.entries(o)) {
351
+ if (k !== "wait" && !(k in TOOL_KEYS))
352
+ throw new Error(`wakeshift: ${what}'s tools has "${k}": its own tools are wait, subscribe, list and unsubscribe`);
353
+ if (v === undefined)
354
+ continue;
355
+ if (!v || typeof v !== "object" || Array.isArray(v))
356
+ throw new Error(`wakeshift: ${what}'s tools.${k} is an object, { enabled }`);
357
+ for (const [ok, ov] of Object.entries(v)) {
358
+ if (ok !== "enabled")
359
+ throw new Error(`wakeshift: ${what}'s tools.${k} has "${ok}": its options are { enabled }`);
360
+ if (ov !== undefined && typeof ov !== "boolean")
361
+ throw new Error(`wakeshift: ${what}'s tools.${k}.enabled is true or false`);
362
+ }
363
+ }
364
+ const builtin = new Set();
365
+ for (const [k, name] of Object.entries(TOOL_KEYS))
366
+ if (o[k]?.enabled !== false)
367
+ builtin.add(name);
368
+ return { builtin, wait: o.wait?.enabled === true };
369
+ }
370
+ /** @internal The warning for AttachOptions.tools' deprecated list form. */
371
+ export function toolsListWarning(what = "attach") {
372
+ return `wakeshift: ${what}'s tools as a list of names is deprecated: pass { subscribe, list, unsubscribe }, each { enabled } (and wait: { enabled: true } for Claude Code's wait tool)`;
373
+ }
331
374
  // What a client says about itself on initialize (2.x: or the request's
332
375
  // envelope), or a request's _meta; else the name identify gave (identified).
333
376
  function clientInfoName(host, meta, identified) {
@@ -373,6 +416,9 @@ export const EVENT_TOOL_TEXT = {
373
416
  none: "You have no subscriptions to this app's events.",
374
417
  not_subscribed: "This conversation wasn't subscribed to {event} {arguments}.",
375
418
  notifies: "This tool is how {app} notifies you in this conversation{when}. {App} delivers its notifications through Wakeshift; the result may ask you to start a background listener and to offer the user a one-time install. Follow it.",
419
+ notifies_event: " of this event: {events}",
420
+ notifies_events: " of these events: {events}",
421
+ notifies_events_separator: "; ",
376
422
  personal_no_user: "{event} is personal, and this app couldn't tell who you are: sign in to it first.",
377
423
  polled: "polled by an MCP client",
378
424
  refused: "You may not subscribe to {event} with these arguments.",
@@ -397,6 +443,52 @@ export function notifiesSentence(app, when) {
397
443
  const App = app.charAt(0).toUpperCase() + app.slice(1);
398
444
  return EVENT_TOOL_TEXT.notifies.replace(/\{when\}/g, w ? ` when ${w}` : "").replace(/\{App\}/g, App).replace(/\{app\}/g, app);
399
445
  }
446
+ /**
447
+ * @internal The sentence tools/list adds to a tool that declares the events
448
+ * it subscribes to (attached.events' subscribes): notifies' sentence with
449
+ * the events' own descriptions where when goes, "of this event: <d>" or
450
+ * "of these events: <d1>; <d2>". Each description is on one line, without
451
+ * its trailing period: the sentence adds one. test/subscribes.json pins it.
452
+ */
453
+ export function subscribesSentence(app, descriptions) {
454
+ const ds = eventDescriptions(descriptions);
455
+ if (ds.length === 0)
456
+ return "";
457
+ const of = fill(ds.length === 1 ? "notifies_event" : "notifies_events", { events: ds.join(EVENT_TOOL_TEXT.notifies_events_separator) });
458
+ const App = app.charAt(0).toUpperCase() + app.slice(1);
459
+ return EVENT_TOOL_TEXT.notifies.replace(/\{when\}/g, () => of).replace(/\{App\}/g, () => App).replace(/\{app\}/g, () => app);
460
+ }
461
+ // eventDescriptions is the declared events' descriptions as the sentence
462
+ // quotes them: each on one line, its trailing periods trimmed, each once.
463
+ function eventDescriptions(descriptions) {
464
+ const out = [];
465
+ for (const d of descriptions) {
466
+ const c = (d ?? "").replace(/\s+/g, " ").trim().replace(/[.\s]+$/, "");
467
+ if (c && !out.includes(c))
468
+ out.push(c);
469
+ }
470
+ return out;
471
+ }
472
+ // NOTIFIES_TAIL is the part of every notifies sentence after the app's
473
+ // name, whatever its app and events: a description that has it already
474
+ // says how the app notifies (notifies() wrote it).
475
+ const NOTIFIES_TAIL = EVENT_TOOL_TEXT.notifies.slice(EVENT_TOOL_TEXT.notifies.indexOf("{App}") + "{App}".length);
476
+ /**
477
+ * @internal A tool's description as tools/list gives it for a tool that
478
+ * declares the events it subscribes to: the subscribes sentence after the
479
+ * tool's own description. A description that already has a notifies
480
+ * sentence (notifies(), for custom wording), or no events, leaves it as it
481
+ * was. app is the app's name ("this app" until tools/list names it).
482
+ */
483
+ export function withSubscribes(description, descriptions, app = UNNAMED_APP) {
484
+ const d = (description ?? "").trim();
485
+ if (d.includes(NOTIFIES_TAIL))
486
+ return description;
487
+ const n = subscribesSentence(app.trim() || UNNAMED_APP, descriptions);
488
+ if (!n)
489
+ return description;
490
+ return d ? `${d} ${n}` : n;
491
+ }
400
492
  /** @internal The parts of notifies' sentence that name the app, for "this app": tools/list names it. */
401
493
  export function unnamedNotifiesParts() {
402
494
  const s = notifiesSentence(UNNAMED_APP);
@@ -435,18 +527,21 @@ export function attachEvents(wake, server, events, opts = {}) {
435
527
  checkAttach(events, opts);
436
528
  return attachHost(wake, hostOf(server), events, opts);
437
529
  }
438
- /** @internal What attach refuses before it touches a server: no events, or an own tool it doesn't have. */
530
+ /** @internal What attach refuses before it touches a server: no events, or tools it doesn't know. */
439
531
  export function checkAttach(events, opts, what = "attach") {
440
532
  if (events.length === 0)
441
533
  throw new Error(`wakeshift: ${what} needs at least one event (wake.defineEvent) defined first`);
442
- for (const t of opts.tools ?? []) {
443
- if (!BUILTIN_TOOLS.includes(t))
444
- throw new Error(`wakeshift: ${what}'s tools names "${t}": its own tools are ${BUILTIN_TOOLS.join(", ")}`);
445
- }
534
+ toolChoice(opts.tools, what);
446
535
  }
447
536
  /** @internal attach on a Host: an McpServer's, or a framework adapter's (mastra.ts, xmcp.ts). */
448
537
  export function attachHost(wake, host, events, opts = {}) {
449
- const tools = new Set(opts.tools ?? BUILTIN_TOOLS);
538
+ const choice = toolChoice(opts.tools);
539
+ if (Array.isArray(opts.tools))
540
+ wake.warnOnce("tools-list", toolsListWarning());
541
+ const tools = choice.builtin;
542
+ // The wait tool, only if the app turns it on and the MCP SDK's request
543
+ // handlers can be reached (tasks.ts): otherwise no call says it takes tasks.
544
+ const wait = choice.wait && !!host.handlers();
450
545
  // raw is the MCP SDK's own request context (1.x's extra, 2.x's ctx): what identify gets.
451
546
  const identityOf = (raw) => opts.identify?.(raw) ?? undefined;
452
547
  // Who's calling: the user, and the client's name (the request's, else identify's).
@@ -461,6 +556,7 @@ export function attachHost(wake, host, events, opts = {}) {
461
556
  clientOf: (meta, identified) => clientInfoName(host, meta, identified),
462
557
  events: opts.events ?? {},
463
558
  warnOnce: (key, message) => wake.warnOnce(key, message),
559
+ wait,
464
560
  });
465
561
  const byName = new Map(events.map((e) => [e.name, e]));
466
562
  try {
@@ -472,15 +568,16 @@ export function attachHost(wake, host, events, opts = {}) {
472
568
  if (!host.handlers() && !host.framework) {
473
569
  // An MCP SDK release that keeps its request handlers elsewhere: the native
474
570
  // events and the built-in tools still work, but not what wraps the SDK's own methods.
475
- wake.warnOnce("handlers", "wakeshift: this MCP SDK keeps its request handlers where Wakeshift can't reach them, so wakeshift_wait, the 2026-07-28 tools/list changes and wrapped tools' output schemas are off: check for a newer @wakeshift/sdk");
571
+ wake.warnOnce("handlers", `wakeshift: this MCP SDK keeps its request handlers where Wakeshift can't reach them, so ${choice.wait ? "wakeshift_wait, " : ""}the 2026-07-28 tools/list changes and wrapped tools' output schemas are off: check for a newer @wakeshift/sdk`);
476
572
  }
477
573
  // Clients that speak only protocol 2026-07-28 (OpenClaw): on the MCP SDK
478
574
  // 1.x, on a stateless transport, server/discover and resultType
479
575
  // (discover.ts); on 2.x, its own.
480
576
  serveStateless(host, { bridgedOnly: ["subscribe_events"] });
481
577
  // Clients that take MCP tasks there (Claude Code): Wakeshift's wait tool,
482
- // answered with a task (tasks.ts).
483
- serveTasks(wake, host, callerOf);
578
+ // answered with a task (tasks.ts), if the app turns it on (wait).
579
+ if (wait)
580
+ serveTasks(wake, host, callerOf);
484
581
  // tools/list: a wrapped tool's output schema names wakeshift, which its
485
582
  // structured results carry (toolcall.ts).
486
583
  attached.amendToolsList();
@@ -616,7 +713,7 @@ export function attachHost(wake, host, events, opts = {}) {
616
713
  }
617
714
  try {
618
715
  const out = await wake.requestApp("POST", "/event-subscriptions", {
619
- call: toolCallOf(host.req(raw), client, user, rest, "subscribe_events"),
716
+ call: toolCallOf(host.req(raw), client, user, rest, "subscribe_events", wait),
620
717
  name: ev.name,
621
718
  arguments: args,
622
719
  });
@@ -651,7 +748,7 @@ export function attachHost(wake, host, events, opts = {}) {
651
748
  return fail(fill("need"));
652
749
  const args = ev.parseArgs(rawArgs ?? {});
653
750
  const out = await wake.requestApp("POST", "/event-subscriptions/unsubscribe", {
654
- call: toolCallOf(host.req(raw), client, user, rest, "unsubscribe_events"),
751
+ call: toolCallOf(host.req(raw), client, user, rest, "unsubscribe_events", wait),
655
752
  name: ev.name,
656
753
  arguments: args,
657
754
  });
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  export { Wakeshift, DEFAULT_CORE, SESSION_ARG, HARNESS_ARG, CONVERSATION_ARG, type WakeshiftOptions, type AppInfo, type Duration, type Recognized, type Emitted, type Recipient, type Via, type PublishUrl, type ToolExtra, type Manifest, type AgentScope, type ScopeEvent, } from "./wakeshift.js";
2
2
  export { harnessAgent } from "./harness.js";
3
- export { WakeshiftError, isCode } from "./errors.js";
3
+ export { IdReusedError, WakeshiftError, isCode } from "./errors.js";
4
4
  export { parseKey, signAssertion, type AppKey } from "./key.js";
5
- export { WakeEvent, ArgsError, EVENT_TOOL_TEXT, notifies, type NotifiesOptions, SUBSCRIBE_TOOL_DESCRIPTION, UNSUBSCRIBE_TOOL_DESCRIPTION, LIST_TOOL_DESCRIPTION, BUILTIN_TOOLS, type BuiltinTool, type EventOptions, type EmitOptions, type EventStatus, type EventSubscriptionStatus, MAX_STATUS_SUBSCRIPTIONS, type EventTypeManifest, type AttachOptions, type Identity, type Subscriber, } from "./events.js";
6
- export { CallEnded, type Attached, type AppArgs, type EventsOptions, type Subscription, type ToolCall, type ToolCallExtra, type ToolSubscribeOptions, type WakeArgs, type WakeInputs, type WakeInputsV3, type WakeInputsV4, type WakeKey, type WakeOutputShape, type WakeOutputV3, type WakeOutputV4, type WakeShape, type WrappedCallback, type WrappedResult, } from "./toolcall.js";
5
+ export { WakeEvent, ArgsError, EVENT_TOOL_TEXT, notifies, type NotifiesOptions, SUBSCRIBE_TOOL_DESCRIPTION, UNSUBSCRIBE_TOOL_DESCRIPTION, LIST_TOOL_DESCRIPTION, BUILTIN_TOOLS, type BuiltinTool, type EventOptions, type EmitOptions, type EventStatus, type EventSubscriptionStatus, MAX_STATUS_SUBSCRIPTIONS, type EventTypeManifest, type AttachOptions, type AttachTools, type ToolOptions, type WaitToolOptions, type Identity, type Subscriber, } from "./events.js";
6
+ export { CallEnded, type AnyWakeEvent, type EventArgs, type ToolCallBase, type ToolCallSubscribe, type ToolEventsOptions, type Attached, type AppArgs, type EventsOptions, type Subscription, type ToolCall, type ToolCallExtra, type ToolSubscribeOptions, type WakeArgs, type WakeInputs, type WakeInputsV3, type WakeInputsV4, type WakeKey, type WakeOutputShape, type WakeOutputV3, type WakeOutputV4, type WakeShape, type WrappedCallback, type WrappedResult, } from "./toolcall.js";
7
7
  export type { AuthInfo, CallToolResult, ContentBlock, HostContext, McpServerLike } from "./mcp.js";
8
8
  export { EXTENSION_KEY, TEXT_META, REVISION as EVENTS_REVISION } from "./spec.js";
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export { Wakeshift, DEFAULT_CORE, SESSION_ARG, HARNESS_ARG, CONVERSATION_ARG, } from "./wakeshift.js";
2
2
  export { harnessAgent } from "./harness.js";
3
- export { WakeshiftError, isCode } from "./errors.js";
3
+ export { IdReusedError, WakeshiftError, isCode } from "./errors.js";
4
4
  export { parseKey, signAssertion } from "./key.js";
5
5
  export { WakeEvent, ArgsError, EVENT_TOOL_TEXT, notifies, SUBSCRIBE_TOOL_DESCRIPTION, UNSUBSCRIBE_TOOL_DESCRIPTION, LIST_TOOL_DESCRIPTION, BUILTIN_TOOLS, MAX_STATUS_SUBSCRIPTIONS, } from "./events.js";
6
6
  export { CallEnded, } from "./toolcall.js";
package/dist/mastra.d.ts CHANGED
@@ -2,8 +2,8 @@ import type * as z3 from "zod/v3";
2
2
  import type * as z4 from "zod/v4";
3
3
  import type { ToolCallExtra } from "./mcp.js";
4
4
  import type { ZodRawShapeCompat } from "./zod.js";
5
- import { type AttachOptions, type BuiltinTool, type Identity } from "./events.js";
6
- import { CallEnded, type ToolCall, type WakeArgs, type WakeInputs, type WakeInputsV3, type WakeInputsV4, type WakeOutputShape, type WakeOutputV3, type WakeOutputV4 } from "./toolcall.js";
5
+ import { type AttachOptions, type AttachTools, type BuiltinTool, type Identity } from "./events.js";
6
+ import { CallEnded, type AnyWakeEvent, type ToolCall, type ToolEventsOptions, type WakeArgs, type WakeInputs, type WakeInputsV3, type WakeInputsV4, type WakeOutputShape, type WakeOutputV3, type WakeOutputV4 } from "./toolcall.js";
7
7
  import type { Wakeshift } from "./wakeshift.js";
8
8
  /** The @mastra/mcp releases serve() is built for: 2.x, from 2.7. */
9
9
  export declare const MASTRA_MCP_RANGE = ">=2.7.0 <3.0.0";
@@ -31,6 +31,7 @@ export declare class MastraAttached {
31
31
  private readonly wake;
32
32
  private readonly opts;
33
33
  private readonly wrapped;
34
+ private readonly declared;
34
35
  private readonly scopes;
35
36
  private readonly shapes;
36
37
  private readonly served;
@@ -56,7 +57,13 @@ export declare class MastraAttached {
56
57
  * subscriptions it made. Its inputSchema must come from attached.inputs.
57
58
  * Run outside an MCP request (by an agent, a workflow or Studio), the
58
59
  * handler gets a call whose subscribe throws.
60
+ *
61
+ * subscribes lists the events the tool subscribes to, as attach's
62
+ * attached.events takes it: call.subscribe takes only those, and
63
+ * tools/list says the tool is how the app notifies the agent of them.
64
+ * `execute: attached.events("watch_ci", { subscribes: [ciFinished] }, async (args, call) => …)`.
59
65
  */
66
+ events<P extends Record<string, any> = Record<string, any>, R = unknown, const E extends readonly AnyWakeEvent[] = readonly AnyWakeEvent[]>(name: string, opts: ToolEventsOptions<E>, handler: (args: P, call: ToolCall<MastraContext, E[number]>, context: MastraToolContext) => R | Promise<R>): (input: P & WakeArgs, context?: any) => Promise<R>;
60
67
  events<P extends Record<string, any> = Record<string, any>, R = unknown>(name: string, handler: (args: P, call: ToolCall<MastraContext>, context: MastraToolContext) => R | Promise<R>): (input: P & WakeArgs, context?: any) => Promise<R>;
61
68
  /**
62
69
  * Serves Wakeshift from your MCPServer (@mastra/mcp 2.7 or later, 2.x),
@@ -76,4 +83,4 @@ export declare class MastraAttached {
76
83
  * ctx.http?.authInfo).
77
84
  */
78
85
  export declare function attachMastra(wake: Wakeshift, opts?: MastraAttachOptions): MastraAttached;
79
- export { CallEnded, type BuiltinTool, type Identity, type ToolCall, type WakeArgs };
86
+ export { CallEnded, type AttachTools, type BuiltinTool, type Identity, type ToolCall, type WakeArgs };
package/dist/mastra.js CHANGED
@@ -27,7 +27,7 @@
27
27
  // id: "watch_ci",
28
28
  // description: "Watch CI on a repo",
29
29
  // inputSchema: attached.inputs(z.object({ owner: z.string(), repo: z.string() })),
30
- // execute: attached.events("watch_ci", async ({ owner, repo }, call) => {
30
+ // execute: attached.events("watch_ci", { subscribes: [ciFinished] }, async ({ owner, repo }, call) => {
31
31
  // await call.subscribe(ciFinished, { owner, repo })
32
32
  // return `Watching CI on ${owner}/${repo}.`
33
33
  // }),
@@ -60,6 +60,8 @@ export class MastraAttached {
60
60
  wake;
61
61
  opts;
62
62
  wrapped = new Set();
63
+ // The events each wrapped tool declares (subscribes), by tool.
64
+ declared = new Map();
63
65
  // The call each wrapped tool's execute belongs to, by its request (ctx.mcpReq,
64
66
  // which Mastra passes the tool as context.mcp.extra.mcpReq).
65
67
  scopes = new WeakMap();
@@ -79,23 +81,18 @@ export class MastraAttached {
79
81
  output(schema) {
80
82
  return this.shapes.output(schema);
81
83
  }
82
- /**
83
- * Wraps a Mastra tool's execute, for the tool your MCPServer serves under
84
- * the same name (its key in `tools`): `execute: attached.events("watch_ci",
85
- * async (args, call) => …)`. handler gets your own arguments, a ToolCall
86
- * and Mastra's tool context, and returns what a Mastra tool returns (a
87
- * string, or your output). The reply an MCP client gets carries what
88
- * Wakeshift adds for the conversation; a call that fails ends the
89
- * subscriptions it made. Its inputSchema must come from attached.inputs.
90
- * Run outside an MCP request (by an agent, a workflow or Studio), the
91
- * handler gets a call whose subscribe throws.
92
- */
93
- events(name, handler) {
84
+ events(name, optsOrHandler, maybeHandler) {
94
85
  if (!name || typeof name !== "string")
95
86
  throw new Error("wakeshift: attached.events needs the tool's name, as your MCPServer serves it");
87
+ const declared = typeof optsOrHandler === "function" ? null : this.shapes.checkDeclared(name, optsOrHandler);
88
+ const handler = (typeof optsOrHandler === "function" ? optsOrHandler : maybeHandler);
96
89
  if (typeof handler !== "function")
97
90
  throw new Error("wakeshift: attached.events needs a handler");
98
91
  this.wrapped.add(name);
92
+ if (declared)
93
+ this.declared.set(name, declared);
94
+ else
95
+ this.declared.delete(name);
99
96
  return async (input, context) => {
100
97
  const key = context?.mcp?.extra?.mcpReq;
101
98
  const scope = key && typeof key === "object" ? this.scopes.get(key) : undefined;
@@ -150,7 +147,7 @@ export class MastraAttached {
150
147
  const events = this.wake.serveEvents();
151
148
  const attached = attachHost(this.wake, host, events, this.opts);
152
149
  for (const name of this.wrapped)
153
- attached.markWrapped(name);
150
+ attached.markWrapped(name, this.declared.get(name) ?? null);
154
151
  if (theirs.size > 0)
155
152
  shareEvents(handlers, theirs, new Set(events.map((e) => e.name)));
156
153
  this.wrapCalls(handlers, attached);
package/dist/tasks.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // MCP Tasks as a wake path (docs/harnesses.md, "Claude Code: MCP tasks as a
2
2
  // wake path"): for a client that takes MCP tasks, on protocol 2026-07-28
3
- // (discover.ts), attach serves Wakeshift's wait tool, wakeshift_wait. Its
3
+ // (discover.ts), attach with wait: true serves Wakeshift's wait tool,
4
+ // wakeshift_wait (off by default: AttachOptions.wait). Its
4
5
  // call is answered with a task that completes when the conversation's next
5
6
  // Wakeshift messages arrive; the client follows it in the background
6
7
  // (tasks/get), and a completed task wakes an idle conversation. Wakeshift
@@ -58,7 +59,7 @@ export function serveTasks(wake, host, callerOf) {
58
59
  }
59
60
  const call = (raw, args) => {
60
61
  const { user, client } = callerOf(raw);
61
- return toolCallOf(host.req(raw), client, user, args, WAIT_TOOL);
62
+ return toolCallOf(host.req(raw), client, user, args, WAIT_TOOL, true);
62
63
  };
63
64
  const takes = (raw) => {
64
65
  const req = host.req(raw);
@@ -97,12 +97,16 @@ export interface ToolSubscribeOptions {
97
97
  /** Names a new session (agent@label), for an app acting for its agents. */
98
98
  label?: string;
99
99
  }
100
+ /** Any of the app's events: what call.subscribe takes in a tool that doesn't declare its events. */
101
+ export type AnyWakeEvent = WakeEvent<any, any, any>;
102
+ /** The arguments event takes, as call.subscribe takes them. */
103
+ export type EventArgs<E> = E extends WakeEvent<infer A, any, any> ? SchemaInput<A> : never;
100
104
  /**
101
- * One call of a wrapped tool (attached.events): who's calling, and the
102
- * calls that subscribe the calling conversation. Only the wrapper makes
103
- * one, and it works until the handler returns (then CallEnded).
105
+ * What every ToolCall has, whatever its tool subscribes to: who's calling,
106
+ * unsubscribe and resolveSession. Only the wrapper makes one, and it works
107
+ * until the handler returns (then CallEnded).
104
108
  */
105
- export interface ToolCall<C = any> {
109
+ export interface ToolCallBase<C = any> {
106
110
  /** Who attach's identify says is calling (undefined for none). */
107
111
  readonly user: string | undefined;
108
112
  /** The tool being called. */
@@ -121,23 +125,49 @@ export interface ToolCall<C = any> {
121
125
  readonly recognized: Recognized | null;
122
126
  /** The call's request context, as your MCP SDK passes it to the tool's callback: 2.x's ctx, 1.x's extra. */
123
127
  readonly extra: C;
128
+ /**
129
+ * Ends the calling conversation's subscription to event with args,
130
+ * resolving to whether one ended. For a native caller it ends the user's
131
+ * own (webhook) subscriptions to the event with those arguments.
132
+ */
133
+ unsubscribe<A extends AnySchema>(event: WakeEvent<A, any>, args: SchemaInput<A>): Promise<boolean>;
134
+ /** Finds or starts the calling conversation's session without subscribing it (it doesn't make it wakeable). */
135
+ resolveSession(): Promise<string>;
136
+ }
137
+ /** call.subscribe, for a tool that subscribes to the events E. */
138
+ export interface ToolCallSubscribe<E extends AnyWakeEvent = AnyWakeEvent> {
124
139
  /**
125
140
  * Subscribes the calling conversation to event with args, or renews its
126
141
  * subscription. The reply then carries what the conversation needs to be
127
142
  * woken (one block for the call, however many subscribes it makes), and
128
143
  * if the call fails, the subscription is ended. For a native caller
129
144
  * nothing is made. Your tool decides who may: authorize isn't consulted.
130
- * A personal event needs a user (identify's).
145
+ * A personal event needs a user (identify's). event is one of the events
146
+ * the tool declares (attached.events' subscribes).
131
147
  */
132
- subscribe<A extends AnySchema>(event: WakeEvent<A, any>, args: SchemaInput<A>, opts?: ToolSubscribeOptions): Promise<Subscription>;
148
+ subscribe<Ev extends E>(event: Ev, args: EventArgs<Ev>, opts?: ToolSubscribeOptions): Promise<Subscription>;
149
+ }
150
+ /**
151
+ * One call of a wrapped tool (attached.events): who's calling, and the
152
+ * calls that subscribe the calling conversation. E is the events the tool
153
+ * declares (`attached.events(name, { subscribes: [...] }, handler)`):
154
+ * call.subscribe takes only those, and a tool that declares none has no
155
+ * call.subscribe. A tool wrapped without subscribes gets a call whose
156
+ * subscribe takes any of the app's events, and warns when it's used.
157
+ */
158
+ export type ToolCall<C = any, E extends AnyWakeEvent = AnyWakeEvent> = ToolCallBase<C> & ([E] extends [never] ? {} : ToolCallSubscribe<E>);
159
+ /** attached.events' options for a tool. */
160
+ export interface ToolEventsOptions<E extends readonly AnyWakeEvent[] = readonly AnyWakeEvent[]> {
133
161
  /**
134
- * Ends the calling conversation's subscription to event with args,
135
- * resolving to whether one ended. For a native caller it ends the user's
136
- * own (webhook) subscriptions to the event with those arguments.
162
+ * The events the tool subscribes the calling conversation to (with
163
+ * call.subscribe). tools/list then adds a sentence to the tool's
164
+ * description saying the tool is how the app notifies the agent of them,
165
+ * in their own descriptions' words, and that the result may ask to start
166
+ * a listener and offer an install. Agents set Wakeshift's part of a
167
+ * result aside as unrequested unless the tool's description says so. An
168
+ * empty list declares that the tool subscribes to nothing.
137
169
  */
138
- unsubscribe<A extends AnySchema>(event: WakeEvent<A, any>, args: SchemaInput<A>): Promise<boolean>;
139
- /** Finds or starts the calling conversation's session without subscribing it (it doesn't make it wakeable). */
140
- resolveSession(): Promise<string>;
170
+ subscribes: E;
141
171
  }
142
172
  /** A ToolCall used after its handler returned. */
143
173
  export declare class CallEnded extends Error {
@@ -170,6 +200,7 @@ export declare const WAKESHIFT_FIELD = "wakeshift";
170
200
  */
171
201
  export declare class Attached<C = any> {
172
202
  private readonly at;
203
+ private readonly declared;
173
204
  /**
174
205
  * A tool's input schema plus Wakeshift's three optional arguments
175
206
  * (session_key, harness and conversation_id), for a wrapped tool's
@@ -199,10 +230,23 @@ export declare class Attached<C = any> {
199
230
  * a CallToolResult; the reply gets Wakeshift's block. The tool's
200
231
  * inputSchema must come from attached.inputs (a type error otherwise).
201
232
  * Register it on the server passed to attach.
233
+ *
234
+ * subscribes lists the events the tool subscribes to: call.subscribe
235
+ * takes only those, and tools/list adds a sentence to the tool's
236
+ * description saying it's how the app notifies the agent of them.
237
+ *
238
+ * attached.events("watch_build", { subscribes: [buildFinished] }, async ({ build }, call) => { … })
239
+ *
240
+ * Without subscribes (attached.events(name, handler)), call.subscribe
241
+ * takes any of the app's events but warns: declare them.
202
242
  */
243
+ events<A extends WakeArgs, const E extends readonly AnyWakeEvent[]>(name: string, opts: ToolEventsOptions<E>, handler: (args: Omit<A, WakeKey>, call: ToolCall<C, E[number]>) => WrappedResult | Promise<WrappedResult>): WrappedCallback<A, C>;
203
244
  events<A extends WakeArgs>(name: string, handler: (args: Omit<A, WakeKey>, call: ToolCall<C>) => WrappedResult | Promise<WrappedResult>): WrappedCallback<A, C>;
245
+ private declaredOf;
246
+ private declare;
204
247
  private run;
205
248
  amendToolsList(): void;
249
+ private describeTools;
206
250
  /**
207
251
  * notifies(description) with no app named says "this app": tools/list
208
252
  * names the app instead, as the console has it (GET /app, asked once). A
package/dist/toolcall.js CHANGED
@@ -10,12 +10,12 @@
10
10
  // const attached = wake.attach(server, { identify: (extra) => ({ user: userOf(extra) }) })
11
11
  // server.registerTool("join",
12
12
  // { description: "…", inputSchema: attached.inputs({ table: z.string() }) },
13
- // attached.events("join", async ({ table }, call) => {
13
+ // attached.events("join", { subscribes: [turn] }, async ({ table }, call) => {
14
14
  // const sub = await call.subscribe(turn, { table }, { reason: `Playing at ${table}` })
15
15
  // await casino.seat(call.user!, table, sub.id)
16
16
  // return `You're seated at ${table}.`
17
17
  // }))
18
- import { UNNAMED_APP, unnamedNotifiesParts } from "./events.js";
18
+ import { UNNAMED_APP, unnamedNotifiesParts, withSubscribes } from "./events.js";
19
19
  import { z } from "zod";
20
20
  import * as z3 from "zod/v3";
21
21
  import * as z4 from "zod/v4";
@@ -101,6 +101,27 @@ export const CONVERSATION_META = [
101
101
  "wakeshift/conversation",
102
102
  "x-codex-turn-metadata.thread_id",
103
103
  ];
104
+ /**
105
+ * @internal What a subscribe the tool doesn't declare does: warns once per
106
+ * tool (this release) or throws (error: the next release). Tests flip it.
107
+ */
108
+ export const undeclaredSubscribe = { error: false };
109
+ // eventRef is how a warning writes an event in its fix: the name of the
110
+ // event wake.defineEvent returned.
111
+ const eventRef = (name) => `<${name}'s event>`;
112
+ /** @internal The warning (or, with undeclaredSubscribe.error, the error) for a subscribe the tool doesn't declare. */
113
+ export function undeclaredMessage(tool, event, declared, error) {
114
+ const all = [...(declared ?? []), event];
115
+ const fix = `attached.events(${JSON.stringify(tool)}, { subscribes: [${all.map(eventRef).join(", ")}] }, handler)`;
116
+ const what = declared === null
117
+ ? `the tool ${tool} subscribed to ${event} without declaring the events it subscribes to`
118
+ : declared.length === 0
119
+ ? `the tool ${tool} subscribed to ${event}, but declares that it subscribes to nothing`
120
+ : `the tool ${tool} subscribed to ${event}, which its subscribes doesn't list (${declared.join(", ")})`;
121
+ if (error)
122
+ return `wakeshift: ${what}, so the subscribe was refused. Fix: ${fix}.`;
123
+ return `wakeshift: ${what}, so tools/list doesn't tell the agent that the tool is how this app notifies it. Fix: ${fix}. It works for now; from the next release an undeclared subscribe throws.`;
124
+ }
104
125
  /** A ToolCall used after its handler returned. */
105
126
  export class CallEnded extends Error {
106
127
  constructor() {
@@ -229,6 +250,8 @@ function detailOf(err) {
229
250
  export class CallScope {
230
251
  at;
231
252
  args;
253
+ /** The events the tool declares (null: it declares none: attached.events without subscribes). */
254
+ declared;
232
255
  user;
233
256
  tool;
234
257
  native = false;
@@ -248,9 +271,10 @@ export class CallScope {
248
271
  queue = Promise.resolve();
249
272
  // The client's name identify gave, for a request that names none.
250
273
  identified;
251
- constructor(at, tool, extra, args) {
274
+ constructor(at, tool, extra, args, declared = null) {
252
275
  this.at = at;
253
276
  this.args = args;
277
+ this.declared = declared;
254
278
  this.tool = tool;
255
279
  this.extra = extra;
256
280
  const id = at.identify(extra);
@@ -276,7 +300,7 @@ export class CallScope {
276
300
  harness: this.args.harness,
277
301
  conversation: this.args.conversation,
278
302
  tool: this.tool,
279
- tasks: takesTasks(meta, req),
303
+ tasks: !!this.at.wait && takesTasks(meta, req),
280
304
  });
281
305
  }
282
306
  // request runs one request of the call to Wakeshift, after any before it
@@ -295,9 +319,21 @@ export class CallScope {
295
319
  throw new WakeshiftError(404, "unknown_event", `${event?.name ?? "that"} is another app's event: this app doesn't offer it`);
296
320
  }
297
321
  }
322
+ // checkDeclared warns once per tool (or throws, once undeclared subscribes
323
+ // are an error) when the tool doesn't declare event.
324
+ checkDeclared(event) {
325
+ const d = this.declared;
326
+ if (d && d.some((e) => e === event || e.name === event.name))
327
+ return;
328
+ const names = d ? d.map((e) => e.name) : null;
329
+ if (undeclaredSubscribe.error)
330
+ throw new Error(undeclaredMessage(this.tool, event.name, names, true));
331
+ this.at.warnOnce("undeclared:" + this.tool, undeclaredMessage(this.tool, event.name, names, false));
332
+ }
298
333
  subscribe(event, args, opts = {}) {
299
334
  return this.request(async () => {
300
335
  this.checkEvent(event);
336
+ this.checkDeclared(event);
301
337
  const known = event.parseArgs(args);
302
338
  if (this.native) {
303
339
  this.addNative(event.name, known);
@@ -494,6 +530,8 @@ export class Attached {
494
530
  at;
495
531
  /** @internal */
496
532
  wrapped = new Map();
533
+ // The events each wrapped tool declares (attached.events' subscribes), by tool.
534
+ declared = new Map();
497
535
  /** @internal Use wake.attach. */
498
536
  constructor(at) {
499
537
  this.at = at;
@@ -526,23 +564,38 @@ export class Attached {
526
564
  throw new Error("wakeshift: attached.output adds wakeshift itself: leave it out of the shape");
527
565
  return { ...shape, ...fieldsFor(shape, "output", this.at.host) };
528
566
  }
529
- /**
530
- * Wraps a tool's callback, for `server.registerTool(name, …)` with the
531
- * same name (MCP doesn't tell a callback its tool's name): the handler
532
- * gets the tool's own arguments and a ToolCall, and returns a string or
533
- * a CallToolResult; the reply gets Wakeshift's block. The tool's
534
- * inputSchema must come from attached.inputs (a type error otherwise).
535
- * Register it on the server passed to attach.
536
- */
537
- events(name, handler) {
567
+ events(name, optsOrHandler, maybeHandler) {
538
568
  if (!name || typeof name !== "string")
539
569
  throw new Error("wakeshift: attached.events needs the tool's name, as registered");
570
+ const declared = typeof optsOrHandler === "function" ? null : this.declaredOf(name, optsOrHandler);
571
+ const handler = typeof optsOrHandler === "function" ? optsOrHandler : maybeHandler;
540
572
  if (typeof handler !== "function")
541
573
  throw new Error("wakeshift: attached.events needs a handler");
542
574
  const cb = (async (args, extra) => this.run(name, cb, handler, args ?? {}, extra));
543
575
  this.wrapped.set(name, cb);
576
+ this.declare(name, declared);
544
577
  return cb;
545
578
  }
579
+ // declaredOf checks attached.events' options: the tool's subscribes, each
580
+ // one of this app's events.
581
+ declaredOf(name, opts) {
582
+ const list = opts?.subscribes;
583
+ if (!Array.isArray(list))
584
+ throw new Error(`wakeshift: attached.events("${name}", options, handler): options.subscribes lists the events the tool subscribes to ([] for none)`);
585
+ for (const e of list) {
586
+ if (!e || typeof e.owner !== "function" || e.owner() !== this.at.wake) {
587
+ throw new Error(`wakeshift: attached.events("${name}", …): subscribes lists ${e?.name ?? String(e)}, which isn't one of this app's events (wake.defineEvent)`);
588
+ }
589
+ }
590
+ return [...list];
591
+ }
592
+ // declare records the events a tool declares (null: none declared).
593
+ declare(name, declared) {
594
+ if (declared)
595
+ this.declared.set(name, declared);
596
+ else
597
+ this.declared.delete(name);
598
+ }
546
599
  async run(name, cb, handler, input, extra) {
547
600
  const host = this.at.host;
548
601
  const tool = host.registeredTool(name);
@@ -578,7 +631,7 @@ export class Attached {
578
631
  */
579
632
  begin(name, input, extra) {
580
633
  const str = (k) => (typeof input[k] === "string" && input[k] ? input[k] : undefined);
581
- const call = new CallScope(this.at, name, extra, { session: str(SESSION_ARG), harness: str(HARNESS_ARG), conversation: str(CONVERSATION_ARG) });
634
+ const call = new CallScope(this.at, name, extra, { session: str(SESSION_ARG), harness: str(HARNESS_ARG), conversation: str(CONVERSATION_ARG) }, this.declared.get(name) ?? null);
582
635
  const appArgs = {};
583
636
  for (const [k, v] of Object.entries(input ?? {}))
584
637
  if (!WAKE_KEYS.includes(k))
@@ -639,10 +692,15 @@ export class Attached {
639
692
  * sets the handler when the first tool is registered, perhaps after
640
693
  * attach, so a later set is wrapped too (as tasks.ts does).
641
694
  */
642
- /** @internal Marks name as a wrapped tool, for tools/list (a framework adapter's tool, wrapped outside this handle). */
643
- markWrapped(name) {
695
+ /** @internal Marks name as a wrapped tool, for tools/list (a framework adapter's tool, wrapped outside this handle), with the events it declares (null: none). */
696
+ markWrapped(name, declared = null) {
644
697
  if (!this.wrapped.has(name))
645
698
  this.wrapped.set(name, true);
699
+ this.declare(name, declared);
700
+ }
701
+ /** @internal The events a framework adapter's tool declares, checked as attached.events checks them. */
702
+ checkDeclared(name, opts) {
703
+ return this.declaredOf(name, opts);
646
704
  }
647
705
  amendToolsList() {
648
706
  const handlers = this.at.host.handlers();
@@ -652,6 +710,7 @@ export class Attached {
652
710
  const out = await h(request, extra);
653
711
  if (out && Array.isArray(out.tools)) {
654
712
  this.amendTools(out.tools);
713
+ this.describeTools(out.tools);
655
714
  await this.nameApp(out.tools);
656
715
  }
657
716
  return out;
@@ -662,6 +721,23 @@ export class Attached {
662
721
  const set = handlers.set.bind(handlers);
663
722
  handlers.set = (method, h) => set(method, method === "tools/list" ? wrap(h) : h);
664
723
  }
724
+ // describeTools adds the subscribes sentence to each tool that declares
725
+ // events (withSubscribes; "this app" until nameApp names it). A tool's
726
+ // own notifies() sentence stays the only one.
727
+ describeTools(tools) {
728
+ for (let i = 0; i < tools.length; i++) {
729
+ const t = tools[i];
730
+ if (!t || typeof t !== "object" || typeof t.name !== "string")
731
+ continue;
732
+ const evs = this.declared.get(t.name);
733
+ if (!evs || evs.length === 0)
734
+ continue;
735
+ const d = typeof t.description === "string" ? t.description : "";
736
+ const described = withSubscribes(d, evs.map((e) => e.description));
737
+ if (described !== d)
738
+ tools[i] = { ...t, description: described };
739
+ }
740
+ }
665
741
  /**
666
742
  * notifies(description) with no app named says "this app": tools/list
667
743
  * names the app instead, as the console has it (GET /app, asked once). A
@@ -78,11 +78,15 @@ export type Recipient = {
78
78
  };
79
79
  /** What an event's emit did. */
80
80
  export interface Emitted {
81
- /** Conversations it woke (or will). */
81
+ /** Conversations it reached: each woken now, or as soon as its agent can take it. */
82
82
  matched: number;
83
- /** An earlier emit had this occurrence's id. */
83
+ /**
84
+ * A retry: an earlier emit, the same as this one, had its id, and this is
85
+ * that emit's result (nothing new was sent). The id on a different emit
86
+ * throws IdReusedError instead.
87
+ */
84
88
  duplicate: boolean;
85
- /** User and native recipients with no live subscription it reached (an emit to several recipients only). */
89
+ /** User and native recipients with no live subscription it reached, whether `to` names one or many. */
86
90
  unmatched: (string | Recipient)[];
87
91
  /** Subscription recipients no longer live (or not that user's). */
88
92
  ended: (string | Recipient)[];
@@ -220,7 +224,7 @@ export declare class Wakeshift {
220
224
  * })
221
225
  * await message.emit({ channel: "eng" }, { from: "ada", text: "shipped" })
222
226
  */
223
- defineEvent<A extends AnySchema, P extends AnySchema>(name: string, opts: EventOptions<A, P>): WakeEvent<A, P>;
227
+ defineEvent<A extends AnySchema, P extends AnySchema, N extends string = string>(name: N, opts: EventOptions<A, P>): WakeEvent<A, P, N>;
224
228
  /**
225
229
  * Attaches Wakeshift to your MCP server, before you connect it: the one
226
230
  * call that connects the two. Today it serves the defined events: MCP
package/dist/wakeshift.js CHANGED
@@ -1,4 +1,4 @@
1
- import { WakeshiftError } from "./errors.js";
1
+ import { IdReusedError, WakeshiftError } from "./errors.js";
2
2
  import { v1Req } from "./host.js";
3
3
  import { WakeEvent, attachEvents } from "./events.js";
4
4
  import { parseKey, signAssertion, isTestKey } from "./key.js";
@@ -64,7 +64,7 @@ export function packCall(c) {
64
64
  * others): its _meta and User-Agent, the client's name, the user, and
65
65
  * session_key, harness and conversation_id from its arguments.
66
66
  */
67
- export function toolCallOf(extra, client, user, args, tool) {
67
+ export function toolCallOf(extra, client, user, args, tool, wait) {
68
68
  const req = v1Req(extra); // a request already read passes through
69
69
  return packCall({
70
70
  meta: req.meta,
@@ -75,7 +75,7 @@ export function toolCallOf(extra, client, user, args, tool) {
75
75
  harness: str(args, HARNESS_ARG),
76
76
  conversation: str(args, CONVERSATION_ARG),
77
77
  tool,
78
- tasks: takesTasks(req.meta, req),
78
+ tasks: wait && takesTasks(req.meta, req),
79
79
  });
80
80
  }
81
81
  // str is a tool argument's value if it's a string.
@@ -366,13 +366,20 @@ export class Wakeshift {
366
366
  const seen = this.sends;
367
367
  let out;
368
368
  try {
369
- out = await this.request("POST", "/events/emit", body);
369
+ try {
370
+ out = await this.request("POST", "/events/emit", body);
371
+ }
372
+ catch (err) {
373
+ if (!(err instanceof WakeshiftError && err.status === 404 && err.code === "unknown_event"))
374
+ throw err;
375
+ await this.resendManifest(seen);
376
+ out = await this.request("POST", "/events/emit", body);
377
+ }
370
378
  }
371
379
  catch (err) {
372
- if (!(err instanceof WakeshiftError && err.status === 404 && err.code === "unknown_event"))
373
- throw err;
374
- await this.resendManifest(seen);
375
- out = await this.request("POST", "/events/emit", body);
380
+ if (err instanceof WakeshiftError && err.status === 409 && err.code === "id_reused")
381
+ throw new IdReusedError(err.detail, { cause: err });
382
+ throw err;
376
383
  }
377
384
  return { matched: out.matched, duplicate: !!out.duplicate, unmatched: recipientsOf(out.unmatched), ended: recipientsOf(out.ended) };
378
385
  }
package/dist/xmcp.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { AuthInfo, CallToolResult } from "./mcp.js";
2
2
  import type { ZodRawShapeCompat } from "./zod.js";
3
- import { BUILTIN_TOOLS, type BuiltinTool, type Identity } from "./events.js";
4
- import { type ToolCall, type WakeArgs, type WakeInputs, type WrappedResult } from "./toolcall.js";
3
+ import { BUILTIN_TOOLS, type AttachTools, type BuiltinTool, type Identity } from "./events.js";
4
+ import { type AnyWakeEvent, type ToolCall, type ToolEventsOptions, type WakeArgs, type WakeInputs, type WrappedResult } from "./toolcall.js";
5
5
  import type { Wakeshift } from "./wakeshift.js";
6
6
  /** The request context xmcp passes a tool's handler, its second argument: what Wakeshift reads of it. */
7
7
  export interface XmcpExtra {
@@ -27,8 +27,12 @@ export interface XmcpAttachOptions {
27
27
  * have no user, so personal events and list_subscriptions refuse.
28
28
  */
29
29
  identify?(extra: XmcpExtra): Identity | undefined;
30
- /** Which of attach's own tools attached.tool gives (default: all three). */
31
- tools?: readonly BuiltinTool[];
30
+ /**
31
+ * Which of attach's own tools attached.tool gives, each `{ enabled }`
32
+ * (default: all three). xmcp can't serve the wait tool. Deprecated: a
33
+ * list of names.
34
+ */
35
+ tools?: Omit<AttachTools, "wait"> | readonly BuiltinTool[];
32
36
  }
33
37
  /** One of attach's own tools as an xmcp tool file exports it. */
34
38
  export interface XmcpTool {
@@ -59,8 +63,26 @@ export declare class XmcpAttached {
59
63
  * a CallToolResult; the reply gets what Wakeshift adds for the
60
64
  * conversation, and a call that fails ends the subscriptions it made. Its
61
65
  * schema must come from attached.inputs.
66
+ *
67
+ * subscribes lists the events the tool subscribes to, as attach's
68
+ * attached.events takes it: call.subscribe takes only those. xmcp writes
69
+ * tools/list itself, so give the tool's metadata the same events with
70
+ * attached.describe.
62
71
  */
72
+ events<P extends Record<string, any> = Record<string, any>, const E extends readonly AnyWakeEvent[] = readonly AnyWakeEvent[]>(name: string, opts: ToolEventsOptions<E>, handler: (args: P, call: ToolCall<XmcpExtra, E[number]>) => WrappedResult | Promise<WrappedResult>): (args: P & WakeArgs, extra: XmcpExtra) => Promise<CallToolResult>;
63
73
  events<P extends Record<string, any> = Record<string, any>>(name: string, handler: (args: P, call: ToolCall<XmcpExtra>) => WrappedResult | Promise<WrappedResult>): (args: P & WakeArgs, extra: XmcpExtra) => Promise<CallToolResult>;
74
+ /**
75
+ * A subscribing tool's description, for its tool file's metadata: your
76
+ * description, then the sentence attach's tools/list adds for the events
77
+ * the tool declares (their own descriptions: "This tool is how this app
78
+ * notifies you in this conversation of this event: …"). xmcp writes
79
+ * tools/list itself, so the sentence goes in the metadata, naming the
80
+ * app as app ("this app" if not given):
81
+ * `export const metadata = { name: "watch_ci", description: attached.describe("Watch CI on a repo.", { subscribes: [ciFinished], app: "CI bot" }) }`.
82
+ */
83
+ describe(description: string, opts: ToolEventsOptions & {
84
+ app?: string;
85
+ }): string;
64
86
  /** One of attach's own tools, for a tool file of its own (src/tools/<name>.ts): its schema, metadata and handler. */
65
87
  tool(name: BuiltinTool): XmcpTool;
66
88
  }
@@ -70,4 +92,4 @@ export declare class XmcpAttached {
70
92
  * own tools that subscribe, and attached.tool for attach's own three.
71
93
  */
72
94
  export declare function attachXmcp(wake: Wakeshift, opts?: XmcpAttachOptions): XmcpAttached;
73
- export { BUILTIN_TOOLS, type BuiltinTool, type Identity, type ToolCall, type WakeArgs, type WrappedResult };
95
+ export { BUILTIN_TOOLS, type AttachTools, type BuiltinTool, type Identity, type ToolCall, type WakeArgs, type WrappedResult };
package/dist/xmcp.js CHANGED
@@ -28,15 +28,15 @@
28
28
  //
29
29
  // // src/tools/watch_ci.ts
30
30
  // export const schema = attached.inputs({ owner: z.string(), repo: z.string() })
31
- // export const metadata = { name: "watch_ci", description: "Watch CI on a repo" }
32
- // export default attached.events("watch_ci", async ({ owner, repo }, call) => { … })
31
+ // export const metadata = { name: "watch_ci", description: attached.describe("Watch CI on a repo.", { subscribes: [ciFinished] }) }
32
+ // export default attached.events("watch_ci", { subscribes: [ciFinished] }, async ({ owner, repo }, call) => { … })
33
33
  //
34
34
  // // src/tools/subscribe_events.ts (and the same for the other two)
35
35
  // const tool = attached.tool("subscribe_events")
36
36
  // export const schema = tool.schema
37
37
  // export const metadata = tool.metadata
38
38
  // export default tool.handler
39
- import { BUILTIN_TOOLS, attachHost, checkAttach } from "./events.js";
39
+ import { BUILTIN_TOOLS, attachHost, checkAttach, withSubscribes } from "./events.js";
40
40
  import { toolsOnlyHost, v1Req } from "./host.js";
41
41
  import { WAKESHIFT_FIELD } from "./toolcall.js";
42
42
  const CLIENT_INFO_META = "io.modelcontextprotocol/clientInfo";
@@ -66,16 +66,10 @@ export class XmcpAttached {
66
66
  inputs(shape) {
67
67
  return this.attached.inputs(shape);
68
68
  }
69
- /**
70
- * Wraps a tool's handler, for the tool file whose metadata names the same
71
- * tool: `export default attached.events("watch_ci", async (args, call) => …)`.
72
- * handler gets your own arguments and a ToolCall, and returns a string or
73
- * a CallToolResult; the reply gets what Wakeshift adds for the
74
- * conversation, and a call that fails ends the subscriptions it made. Its
75
- * schema must come from attached.inputs.
76
- */
77
- events(name, handler) {
78
- const cb = this.attached.events(name, handler);
69
+ events(name, optsOrHandler, maybeHandler) {
70
+ const cb = typeof optsOrHandler === "function"
71
+ ? this.attached.events(name, optsOrHandler)
72
+ : this.attached.events(name, optsOrHandler, maybeHandler);
79
73
  return async (args, extra) => {
80
74
  const out = (await cb(args, extra));
81
75
  // xmcp checks structuredContent against the tool's output schema after
@@ -89,6 +83,21 @@ export class XmcpAttached {
89
83
  return out;
90
84
  };
91
85
  }
86
+ /**
87
+ * A subscribing tool's description, for its tool file's metadata: your
88
+ * description, then the sentence attach's tools/list adds for the events
89
+ * the tool declares (their own descriptions: "This tool is how this app
90
+ * notifies you in this conversation of this event: …"). xmcp writes
91
+ * tools/list itself, so the sentence goes in the metadata, naming the
92
+ * app as app ("this app" if not given):
93
+ * `export const metadata = { name: "watch_ci", description: attached.describe("Watch CI on a repo.", { subscribes: [ciFinished], app: "CI bot" }) }`.
94
+ */
95
+ describe(description, opts) {
96
+ const list = opts?.subscribes;
97
+ if (!Array.isArray(list))
98
+ throw new Error("wakeshift: attached.describe(description, { subscribes }): subscribes lists the events the tool subscribes to");
99
+ return withSubscribes(description, list.map((e) => e.description), opts.app);
100
+ }
92
101
  /** One of attach's own tools, for a tool file of its own (src/tools/<name>.ts): its schema, metadata and handler. */
93
102
  tool(name) {
94
103
  const t = this.own.get(name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wakeshift/sdk",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Let agents subscribe to things in your app: Wakeshift wakes them when you emit an event.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Clawvisor Inc. <support@wakeshift.ai>",