@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/dist/wakeshift.js CHANGED
@@ -1,7 +1,6 @@
1
- import { z } from "zod";
2
1
  import { WakeshiftError } from "./errors.js";
3
2
  import { WakeEvent, attachEvents } from "./events.js";
4
- import { parseKey, signAssertion } from "./key.js";
3
+ import { parseKey, signAssertion, isTestKey } from "./key.js";
5
4
  import { retryRefused } from "./retry.js";
6
5
  /** Wakeshift's production core, the default for every key but an older wst_test_ one. Override with `core` or WAKESHIFT_CORE. */
7
6
  export const DEFAULT_CORE = "https://app.wakeshift.ai";
@@ -14,19 +13,25 @@ export const HARNESS_ARG = "harness";
14
13
  * when its program gives it one (Cursor and Grok Bot: CURSOR_CONVERSATION_ID).
15
14
  */
16
15
  export const CONVERSATION_ARG = "conversation_id";
17
- // What the agent is told each argument is (the core's own words:
18
- // internal/harnesses, whose tests check these match).
19
- const SESSION_DESCRIPTION = "The session key an earlier result gave you, if it did. It's a secret: keep it to this conversation; don't share or log it";
20
- const HARNESS_DESCRIPTION = "The program you're running in: codex, claude-code, cursor (local CLI or IDE), cursor-cloud (Cursor cloud agent), grok-bot, copilot-cli, opencode, antigravity, openclaw (even when it runs your turns in Codex), hermes, chatgpt, or your program's name if it isn't listed";
21
- const CONVERSATION_DESCRIPTION = "Your own ID for this conversation, if your program gives you one. Cursor and Grok Bot: the output of `printenv CURSOR_CONVERSATION_ID`. Codex and Antigravity send it automatically; others leave it empty";
22
- /** What the SDK says, once, when Wakeshift recognised no harness in a tool call. */
23
- export const UNKNOWN_CLIENT_WARNING = "wakeshift: Wakeshift couldn't tell which agent made a tool call (no MCP client name, nothing it knows in the " +
24
- "call's _meta or User-Agent, and no harness argument), so it treats the agent as one it can't wake (its messages are held " +
25
- "for its next call to the tool). The name comes from the client's initialize request, which a server created per request " +
26
- "(stateless Streamable HTTP) never sees; Claude Code, Codex, Antigravity, Copilot CLI, opencode and Cursor are recognised " +
27
- "without it. Spread ...wake.args into the tool's input and pass args to wake.call, so the agent can say (harness); for " +
28
- "other clients, keep one McpServer per MCP session (a stateful transport), or pass the name yourself: " +
29
- "wake.call(extra, { client }) or wake.callOf({ clientName }).";
16
+ // recipientsOf reads an emit's unmatched or ended list: "user",
17
+ // { native: user } or { user, subscription }.
18
+ function recipientsOf(raw) {
19
+ if (!Array.isArray(raw))
20
+ return [];
21
+ const out = [];
22
+ for (const r of raw) {
23
+ if (typeof r === "string")
24
+ out.push(r);
25
+ else if (r && typeof r === "object") {
26
+ const m = r;
27
+ if (typeof m.native === "string" && m.native)
28
+ out.push({ native: m.native });
29
+ else if (typeof m.user === "string")
30
+ out.push({ user: m.user, subscription: typeof m.subscription === "string" ? m.subscription : "" });
31
+ }
32
+ }
33
+ return out;
34
+ }
30
35
  // userAgentOf is the User-Agent of the HTTP request a tool call came in
31
36
  // ("" if there's none, e.g. over stdio).
32
37
  function userAgentOf(extra) {
@@ -36,45 +41,51 @@ function userAgentOf(extra) {
36
41
  const v = headers["user-agent"] ?? headers["User-Agent"];
37
42
  return (Array.isArray(v) ? v[0] : v) ?? "";
38
43
  }
39
- export function clientNameOf(s) {
40
- const info = "getClientVersion" in s ? s.getClientVersion() : s.server.getClientVersion();
41
- return info?.name ?? "";
42
- }
43
- /** Adds Wakeshift's text and _meta to a tool result (a copy). */
44
- export function applyAddition(a, base) {
45
- let out;
46
- if (typeof base === "string") {
47
- const text = (base.trim() + "\n\n" + (a.text ?? "")).trim();
48
- out = { content: text ? [{ type: "text", text }] : [] };
49
- }
50
- else {
51
- out = { ...base, content: [...(base.content ?? [])] };
52
- if (a.text)
53
- out.content.push({ type: "text", text: a.text });
54
- }
55
- if (a.meta && Object.keys(a.meta).length > 0)
56
- out._meta = { ...(out._meta ?? {}), ...a.meta };
44
+ /** @internal Packs a tool call as Wakeshift takes it: empty fields left out, the agent's claims trimmed. */
45
+ export function packCall(c) {
46
+ const out = { client: c.clientName ?? "" };
47
+ if (c.user)
48
+ out.user = c.user;
49
+ if (c.meta && Object.keys(c.meta).length > 0)
50
+ out.meta = c.meta;
51
+ if (c.session)
52
+ out.session = c.session;
53
+ if (c.userAgent)
54
+ out.user_agent = c.userAgent;
55
+ if (c.harness?.trim())
56
+ out.harness = c.harness.trim();
57
+ if (c.conversation?.trim())
58
+ out.conversation = c.conversation.trim();
59
+ if (c.tool?.trim())
60
+ out.tool = c.tool.trim();
57
61
  return out;
58
62
  }
59
- const argsShape = {
60
- [SESSION_ARG]: z.string().optional().describe(SESSION_DESCRIPTION),
61
- [HARNESS_ARG]: z.string().optional().describe(HARNESS_DESCRIPTION),
62
- [CONVERSATION_ARG]: z.string().optional().describe(CONVERSATION_DESCRIPTION),
63
- };
63
+ /**
64
+ * @internal The call to one of attach's own tools (subscribe_events and the
65
+ * others): its _meta and User-Agent, the client's name, the user, and
66
+ * session_key, harness and conversation_id from its arguments.
67
+ */
68
+ export function toolCallOf(extra, client, user, args, tool) {
69
+ return packCall({
70
+ meta: extra?._meta,
71
+ clientName: client,
72
+ user,
73
+ session: str(args, SESSION_ARG),
74
+ userAgent: userAgentOf(extra),
75
+ harness: str(args, HARNESS_ARG),
76
+ conversation: str(args, CONVERSATION_ARG),
77
+ tool,
78
+ });
79
+ }
64
80
  // str is a tool argument's value if it's a string.
65
81
  function str(args, name) {
66
82
  return typeof args?.[name] === "string" ? args[name] : undefined;
67
83
  }
68
84
  /**
69
- * The Wakeshift client for an app: subscribe the conversations that call
70
- * your tools to topics, and publish to them.
85
+ * The Wakeshift client for an app: define the events your app offers,
86
+ * attach them to your MCP server, and emit them when they happen.
71
87
  */
72
88
  export class Wakeshift {
73
- /**
74
- * An older key's mode (wst_test_…, wst_live_…); null for a current key.
75
- * @deprecated Modes mean nothing now, and current keys have none.
76
- */
77
- mode;
78
89
  core;
79
90
  key;
80
91
  // The app's ID: from the key, if it names the app, or else from app().
@@ -89,10 +100,9 @@ export class Wakeshift {
89
100
  throw new Error("wakeshift: no app key (WAKESHIFT_KEY?)");
90
101
  this.key = parseKey(opts.key);
91
102
  this.knownAppId = this.key.appId;
92
- this.mode = this.key.mode;
93
103
  // Test keys come from staging and local consoles, and one sent to
94
104
  // production fails in a way that's hard to read: ask for the core.
95
- const core = opts.core || process.env.WAKESHIFT_CORE || (this.mode === "test" ? "" : DEFAULT_CORE);
105
+ const core = opts.core || process.env.WAKESHIFT_CORE || (isTestKey(opts.key) ? "" : DEFAULT_CORE);
96
106
  if (!core) {
97
107
  throw new Error("wakeshift: no core URL for this test key: set WAKESHIFT_CORE, or pass { core }, to the address of the console the key came from (for example https://app.staging.wakeshift.ai)");
98
108
  }
@@ -113,7 +123,8 @@ export class Wakeshift {
113
123
  return this.knownAppId;
114
124
  }
115
125
  /**
116
- * Which app the key is, as Wakeshift says: its ID and name. Asks once
126
+ * Which app the key is, as Wakeshift says: its ID and name, and the
127
+ * key's. Asks once
117
128
  * (GET /api/apps/v1/app) and remembers the answer; afterwards
118
129
  * `wake.appId` works for any key.
119
130
  */
@@ -125,7 +136,8 @@ export class Wakeshift {
125
136
  }
126
137
  const id = this.knownAppId ?? r.id;
127
138
  this.knownAppId = id;
128
- return { id, name: typeof r.name === "string" ? r.name : "" };
139
+ const str = (v) => (typeof v === "string" ? v : "");
140
+ return { id, name: str(r.name), key: { id: str(r.key?.key_id), name: str(r.key?.name) } };
129
141
  })
130
142
  .catch((err) => {
131
143
  this.appInfo = undefined; // a later call asks again
@@ -145,205 +157,105 @@ export class Wakeshift {
145
157
  warn(message) {
146
158
  this.warnFn(message);
147
159
  }
148
- /** Reports a warning, once per kind. */
160
+ /** @internal Reports a warning, once per kind. */
149
161
  warnOnce(kind, message) {
150
162
  if (this.warned.has(kind))
151
163
  return;
152
164
  this.warned.add(kind);
153
165
  this.warnFn(message);
154
166
  }
155
- /**
156
- * The arguments the agent passes Wakeshift's part of the call in, as a
157
- * zod shape for your subscribe tool's input (and any other tool whose
158
- * results you annotate): `inputSchema: { repo: z.string(), ...wake.args }`.
159
- * session_key (the key a result gives the conversation), harness (the
160
- * program the agent runs in) and conversation_id (its own ID for the
161
- * conversation, where its program gives one). Wakeshift adds nothing to
162
- * tools you don't spread it into.
163
- */
164
- get args() {
165
- return argsShape;
166
- }
167
- /**
168
- * Packs the tool call from an MCP tool callback's `extra`: its _meta and
169
- * User-Agent, the client's name (from `server`, or pass `client`), the
170
- * user, and session_key, harness and conversation_id from `args`.
171
- */
172
- call(extra, opts = {}) {
173
- let client = opts.client;
174
- if (client === undefined) {
175
- if (!opts.server)
176
- throw new Error("wakeshift: wake.call needs the MCP client's name: pass { server } (your McpServer), or { client }");
177
- client = clientNameOf(opts.server);
178
- }
179
- const args = opts.args;
180
- return this.callOf({
181
- meta: extra?._meta,
182
- clientName: client,
183
- user: opts.user,
184
- session: opts.session ?? str(args, SESSION_ARG),
185
- userAgent: userAgentOf(extra),
186
- harness: opts.harness ?? str(args, HARNESS_ARG),
187
- conversation: opts.conversation ?? str(args, CONVERSATION_ARG),
188
- tool: opts.tool,
189
- });
190
- }
191
- /** Packs a tool call without an MCP framework. */
192
- callOf(c) {
193
- const out = { client: c.clientName ?? "" };
194
- if (c.user)
195
- out.user = c.user;
196
- if (c.meta && Object.keys(c.meta).length > 0)
197
- out.meta = c.meta;
198
- if (c.session)
199
- out.session = c.session;
200
- if (c.userAgent)
201
- out.user_agent = c.userAgent;
202
- if (c.harness?.trim())
203
- out.harness = c.harness.trim();
204
- if (c.conversation?.trim())
205
- out.conversation = c.conversation.trim();
206
- if (c.tool?.trim())
207
- out.tool = c.tool.trim();
208
- return out;
209
- }
210
- // recognised reads what the core recognised the caller as, and warns
211
- // once if it was nothing and the agent didn't say (an older core that
212
- // doesn't say: no warning).
213
- recognised(agent) {
214
- if (agent && !agent.harness && !agent.claimed)
215
- this.warnOnce("unknown_client", UNKNOWN_CLIENT_WARNING);
216
- return { harness: agent?.harness ?? null, via: agent?.via ?? null, claimed: agent?.claimed ?? null };
217
- }
218
- /**
219
- * Subscribes the calling conversation to topic (again: refreshes it).
220
- * Check the caller may see the topic first: Wakeshift only refuses another
221
- * user's u/<user>/… topics.
222
- */
223
- async subscribe(call, topic, opts = {}) {
224
- const body = { call, topic, once: !!opts.once };
225
- if (opts.filter && Object.keys(opts.filter).length > 0)
226
- body.filter = opts.filter;
227
- if (opts.replay !== undefined)
228
- body.replay = opts.replay;
229
- if (opts.ttl !== undefined)
230
- body.ttl = opts.ttl;
231
- const out = await this.request("POST", "/topics/subscribe", body);
232
- const agent = this.recognised(out.agent);
233
- const addition = out.addition ?? {};
234
- const s = out.subscription;
235
- return {
236
- id: s.id,
237
- topic: s.topic,
238
- filter: s.filter,
239
- once: s.once,
240
- expiresAt: new Date(s.expires_at),
241
- session: out.session,
242
- addition,
243
- agent,
244
- result: (text) => applyAddition(addition, text),
245
- };
246
- }
247
- /** Ends the conversation's subscription to topic, or to all the app's topics. Returns how many ended. */
248
- async unsubscribe(call, topic) {
249
- const out = await this.request("POST", "/topics/unsubscribe", topic ? { call, topic } : { call });
250
- return out.unsubscribed;
251
- }
252
- /** Wakes every conversation subscribed to one of topics whose filter the event passes (each once). */
253
- async publish(topics, event) {
254
- const out = await this.request("POST", "/topics/publish", { topics: typeof topics === "string" ? [topics] : topics, event });
255
- return { matched: out.matched, duplicate: !!out.duplicate };
256
- }
257
- /** Wakes exactly one subscription's conversation, whatever its filter. */
258
- async send(subscriptionId, event) {
259
- const out = await this.request("POST", `/subscriptions/${encodeURIComponent(subscriptionId)}/send`, { event });
260
- return out.status;
261
- }
262
- /**
263
- * How many conversations are subscribed to topic, and how many are
264
- * connected now, and how. Only reads: nothing is delivered, minted or ended.
265
- */
266
- async topicStatus(topic) {
267
- const out = await this.request("GET", `/topics/status?topic=${encodeURIComponent(topic)}`);
268
- return { topic: out.topic, subscriptions: out.subscriptions, connected: out.connected, via: { stream: 0, poll: 0, hooks: 0, webhook: 0, ...out.via } };
269
- }
270
- /** Whether a subscription is active and its conversation connected now, and how. Only reads. */
271
- async subscriptionStatus(subscriptionId) {
272
- const out = await this.request("GET", `/subscriptions/${encodeURIComponent(subscriptionId)}`);
273
- const s = { id: out.id, topic: out.topic, status: out.status, connected: out.connected };
274
- if (out.via)
275
- s.via = out.via;
276
- if (out.expires_at)
277
- s.expiresAt = new Date(out.expires_at);
278
- return s;
279
- }
280
- /** A capability URL that publishes to topic: a POST's body becomes an event. */
281
- async publishUrl(topic) {
282
- return this.request("POST", "/topics/publish-urls", { topic });
283
- }
284
- /** Stops a publish URL working, a topic's or an event's (410 from then on). */
167
+ /** Stops a publish URL working (410 from then on). */
285
168
  async revokePublishUrl(id) {
286
169
  await this.request("DELETE", `/events/publish-urls/${encodeURIComponent(id)}`);
287
170
  }
288
- /** What Wakeshift would add to any other tool result for this conversation (creates nothing). */
289
- async addition(call) {
290
- const out = await this.request("POST", "/topics/annotate", { call });
291
- return { ...out, agent: this.recognised(out.agent) };
292
- }
293
- /**
294
- * Adds what Wakeshift has for the calling conversation to a tool result
295
- * of yours (a copy): what's waiting for a harness that can't be woken, a
296
- * fresh connector beacon while a Codex thread's connector isn't running.
297
- * Only results you pass here (and a subscription's result()) change.
298
- */
299
- async annotate(call, result) {
300
- return applyAddition(await this.addition(call), result);
301
- }
302
- /** Ends every subscription to topic; each conversation is told. Returns how many ended. */
303
- async closeTopic(topic) {
304
- return (await this.request("POST", "/topics/close", { topic })).closed;
305
- }
306
- /**
307
- * Ends one subscription by its id, telling its conversation that the app
308
- * ended it. Returns false if it had already ended (or there's no such
309
- * subscription of the app's).
310
- */
311
- async endSubscription(subscriptionId) {
312
- return (await this.request("POST", `/subscriptions/${encodeURIComponent(subscriptionId)}/end`, {})).ended;
313
- }
314
- /**
315
- * Ends the subscriptions of every conversation of one of the app's users
316
- * to topic, or to all of the app's topics (they lost access to something),
317
- * telling each conversation. Returns how many ended.
318
- */
319
- async unsubscribeUser(user, topic) {
320
- return (await this.request("POST", "/topics/unsubscribe", topic ? { user, topic } : { user })).unsubscribed;
321
- }
322
171
  /**
323
172
  * Deletes everything Wakeshift keeps for one of the app's users: their
324
- * conversations, subscriptions, events and deliveries, recent publishes to
325
- * their u/<user>/… topics and publish URLs for them, and their ID. Open
326
- * streams close. Returns false if there was nothing to forget.
173
+ * conversations, subscriptions, events and deliveries, publish URLs for
174
+ * them, and their ID. Open streams close. Returns false if there was nothing to forget.
327
175
  */
328
176
  async forgetUser(user) {
329
177
  return (await this.request("POST", "/users/forget", { user })).forgotten;
330
178
  }
331
179
  /**
332
- * Tells Wakeshift what the app may do, replacing what it said before.
333
- * The events you defined (defineEvent) are added unless m has events of
334
- * its own. Returns the manifest as Wakeshift keeps it.
180
+ * Tells Wakeshift what the app may do, replacing what it said before: m,
181
+ * with the events you defined (defineEvent) after m's own events. An
182
+ * event both defined and listed in m.events is an error, and nothing is
183
+ * sent. You needn't call it: a process that serves your tools (attach)
184
+ * sends the manifest automatically, merged into the one Wakeshift has.
185
+ * Call it to remove an event or clear a setting. Once called, m's
186
+ * settings are what every automatic send carries (even if this one
187
+ * failed), with the events defined by then. Returns the manifest as
188
+ * Wakeshift keeps it.
335
189
  */
336
190
  async publishManifest(m) {
337
- const body = m.events === undefined && this.events.size > 0 ? { ...m, events: [...this.events.values()].map((e) => e.manifest) } : m;
338
- const out = await this.request("PUT", "/manifest", body);
191
+ const version = this.manifestVersion; // before the body: a later defineEvent is sent again
192
+ const body = this.manifestBody(m);
339
193
  this.manifestBase = m;
340
- this.published = Promise.resolve();
341
- return out;
194
+ const run = this.serial(() => this.request("PUT", "/manifest", body));
195
+ this.track(run, version);
196
+ return run;
197
+ }
198
+ // serial runs a send after every send before it, explicit or automatic,
199
+ // so the last manifest Wakeshift gets is the newest.
200
+ sending = Promise.resolve();
201
+ serial(send) {
202
+ const run = this.sending.then(send, send);
203
+ this.sending = run.catch(() => undefined);
204
+ return run;
205
+ }
206
+ // track makes a send the one event calls wait for: once it's done,
207
+ // Wakeshift has the manifest for the events defined at version, unless it
208
+ // failed (the next call sends again) or an event was defined since.
209
+ track(send, version) {
210
+ const p = send.then(() => {
211
+ if (this.manifestVersion !== version && this.published === p)
212
+ this.published = null;
213
+ });
214
+ this.published = p;
215
+ p.catch(() => {
216
+ if (this.published === p)
217
+ this.published = null;
218
+ });
219
+ }
220
+ // The manifest as Wakeshift gets it: m's events, then the defined ones,
221
+ // refusing a name in both.
222
+ manifestBody(m) {
223
+ const listed = m.events ?? [];
224
+ for (const name of this.events.keys()) {
225
+ if (listed.some((e) => e.name === name)) {
226
+ throw new Error(`wakeshift: event ${name} is defined with defineEvent and listed in the manifest's events: keep one`);
227
+ }
228
+ }
229
+ const events = [...listed, ...[...this.events.values()].map((e) => e.manifest)];
230
+ const { events: _listed, ...rest } = m;
231
+ return events.length > 0 ? { ...rest, events } : rest;
232
+ }
233
+ // The automatic send, from a process that serves the app's tools: the
234
+ // settings the app last published (only those it set), with the defined
235
+ // events, merged into the manifest Wakeshift has, so it never removes an
236
+ // event or clears a setting another process sent.
237
+ async sendManifest() {
238
+ const body = {};
239
+ for (const [k, v] of Object.entries(this.manifestBody(this.manifestBase ?? {}))) {
240
+ if (v === undefined || v === null || v === "" || v === false || (Array.isArray(v) && v.length === 0))
241
+ continue;
242
+ body[k] = v;
243
+ }
244
+ if (!body.events)
245
+ return; // nothing to add (attach refuses with no events, but never send an empty list)
246
+ await this.request("POST", "/manifest/merge", body);
342
247
  }
343
248
  // ── Events (MCP Events, natively or bridged) ──────────────────────────
344
249
  events = new Map();
345
- manifestBase = {};
250
+ // What publishManifest was last given (null: nothing yet); whether this
251
+ // process serves the app's tools (attach: only then is the manifest sent
252
+ // automatically); whether Wakeshift has the manifest for the events
253
+ // defined now (the send; null: not yet, or a defineEvent since); and how
254
+ // many defineEvents there were.
255
+ manifestBase = null;
256
+ serving = false;
346
257
  published = null;
258
+ manifestVersion = 0;
347
259
  /**
348
260
  * Defines one of the app's event types: what a subscriber picks (args),
349
261
  * what each occurrence carries (payload), and the line an agent reads
@@ -363,7 +275,8 @@ export class Wakeshift {
363
275
  throw new Error(`wakeshift: event ${name} is defined twice`);
364
276
  const ev = new WakeEvent(this, name, opts);
365
277
  this.events.set(name, ev);
366
- this.published = null; // the manifest changed
278
+ this.published = null; // the manifest changed: sent again when next needed
279
+ this.manifestVersion++;
367
280
  return ev;
368
281
  }
369
282
  /**
@@ -375,21 +288,28 @@ export class Wakeshift {
375
288
  * unsubscribe_events and list_subscriptions for every client, so a person
376
289
  * can always see and stop what they're subscribed to.
377
290
  *
378
- * wake.attach(server, { identify: (extra) => ({ user: myUser(extra) }) })
291
+ * const attached = wake.attach(server, { identify: (extra) => ({ user: myUser(extra) }) })
292
+ *
293
+ * It returns the handle your own tools use to subscribe the conversation
294
+ * calling them: wrap a tool's callback with attached.events, and give it
295
+ * Wakeshift's arguments with attached.inputs.
379
296
  */
380
297
  attach(server, opts = {}) {
381
- attachEvents(this, server, [...this.events.values()], opts);
298
+ const attached = attachEvents(this, server, [...this.events.values()], opts);
299
+ this.serving = true; // the manifest is sent automatically from now on
300
+ return attached;
382
301
  }
383
- /** @internal Publishes the manifest with the defined events, once (again after a defineEvent). */
302
+ /**
303
+ * @internal Sends the manifest with the defined events, once (again after
304
+ * a defineEvent), from a process that serves the app's tools (attach). A
305
+ * process that only emits never sends it: its events may be fewer, or
306
+ * older, than its server's.
307
+ */
384
308
  ensureEvents() {
385
- if (!this.published) {
386
- const p = this.publishManifest(this.manifestBase).then(() => undefined);
387
- this.published = p;
388
- p.catch(() => {
389
- if (this.published === p)
390
- this.published = null; // try again next time
391
- });
392
- }
309
+ if (!this.serving)
310
+ return Promise.resolve();
311
+ if (!this.published)
312
+ this.track(this.serial(() => this.sendManifest()), this.manifestVersion);
393
313
  return this.published;
394
314
  }
395
315
  /** @internal An event's emit. */
@@ -401,7 +321,7 @@ export class Wakeshift {
401
321
  if (e.to)
402
322
  body.to = e.to;
403
323
  const out = await this.request("POST", "/events/emit", body);
404
- return { matched: out.matched, duplicate: !!out.duplicate };
324
+ return { matched: out.matched, duplicate: !!out.duplicate, unmatched: recipientsOf(out.unmatched), ended: recipientsOf(out.ended) };
405
325
  }
406
326
  /** @internal A call to the app API (the events machinery's), after the manifest with the defined events is published. */
407
327
  async requestApp(method, path, body) {
@@ -411,8 +331,8 @@ export class Wakeshift {
411
331
  // ── Users' workspaces ──────────────────────────────────────────────────
412
332
  /**
413
333
  * The workspace Wakeshift keeps for one of the app's users (user: the
414
- * app's own ID for them, as in wake.call's user), created on first use.
415
- * Topics keep that user's conversations there.
334
+ * app's own ID for them, as attach's identify gives it), created on first
335
+ * use. Wakeshift keeps that user's conversations there.
416
336
  */
417
337
  async workspace(user, name) {
418
338
  return (await this.request("POST", "/workspaces", { external_id: user, name })).workspace;
@@ -455,13 +375,16 @@ export class Wakeshift {
455
375
  json = undefined;
456
376
  }
457
377
  if (!resp.ok) {
458
- const e = json?.error ?? {};
459
- throw new WakeshiftError(resp.status, e.code ?? "", e.message ?? (text.slice(0, 200) || resp.statusText), {
460
- reason: typeof e.reason === "string" ? e.reason : undefined,
378
+ // Wakeshift's code, message and reason where it sent them as strings, else
379
+ // the body's start, else the status's text, as every SDK reads it (test/errors.json).
380
+ const e = json?.error !== null && typeof json?.error === "object" ? json.error : {};
381
+ const str = (v) => (typeof v === "string" ? v : "");
382
+ throw new WakeshiftError(resp.status, str(e.code), str(e.message) || text.slice(0, 200) || resp.statusText, {
383
+ reason: str(e.reason) || undefined,
461
384
  });
462
385
  }
463
386
  // A 2xx that isn't Wakeshift's JSON: a proxy's page, or a wrong core.
464
- if (json === null || typeof json !== "object") {
387
+ if (json === null || typeof json !== "object" || Array.isArray(json)) {
465
388
  throw new WakeshiftError(resp.status, "bad_response", `Wakeshift at ${this.core} answered ${resp.status} without a JSON object. Check that WAKESHIFT_CORE, or the core option, is the address of the console your key came from`);
466
389
  }
467
390
  return json;