@butlerbot/sdk 0.0.22 → 0.0.24

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.
@@ -91,8 +91,22 @@ export declare class Link {
91
91
  private frameCounter;
92
92
  private currentState;
93
93
  private identity?;
94
- private connecting?;
95
- private readySignal?;
94
+ /**
95
+ * Which socket the callbacks below belong to.
96
+ *
97
+ * Every handler carries the generation it was made for and does nothing once that is
98
+ * no longer current. Without it a late `close` from a socket we have already replaced
99
+ * tears down its successor — one blip turning into a link that flaps for the life of
100
+ * the process.
101
+ */
102
+ private generation;
103
+ /** True from the moment a socket is created until it is open or gone. */
104
+ private attempting;
105
+ private reconnectTimer?;
106
+ /** Settled per attempt: this is what `connect()` awaits. */
107
+ private readonly attemptWaiters;
108
+ /** Settled when the link opens, however many attempts that takes. `ready()` awaits these. */
109
+ private readonly openWaiters;
96
110
  private reconnectAttempt;
97
111
  private reconnectAfterMs;
98
112
  private heartbeat?;
@@ -112,15 +126,45 @@ export declare class Link {
112
126
  get scope(): LinkScopeKind | undefined;
113
127
  on<K extends keyof LinkEvents>(event: K, listener: (...args: LinkEvents[K]) => unknown): string;
114
128
  off<K extends keyof LinkEvents>(event: K, id: string): void;
115
- /** Connects, resolving once every tool and hook has been registered. */
129
+ /**
130
+ * Connects, resolving once every tool and hook has been registered.
131
+ *
132
+ * Rejects if *this* attempt fails. When `reconnect` is on the link keeps trying in
133
+ * the background regardless, so a caller can either await this again or just listen
134
+ * for the `connect` event.
135
+ */
116
136
  connect(): Promise<this>;
117
- /** Resolves when the link is usable, connecting first if it has not been asked to yet. */
137
+ /**
138
+ * Resolves when the link is usable, connecting first if it has not been asked to yet.
139
+ *
140
+ * Bounded by `requestTimeoutMs`, and it never opens a socket of its own while one is
141
+ * in flight: the callers are hook emits and tool replies, which are worth sending now
142
+ * or not at all. Waiting out a long outage here used to mean a new connection per
143
+ * event.
144
+ */
118
145
  ready(): Promise<void>;
119
146
  /** Closes for good. Registrations are released server-side as the socket drops. */
120
147
  close(reason?: string): void;
148
+ /**
149
+ * Starts an attempt, but only when there is nothing to join.
150
+ *
151
+ * The single door to opening a socket: an attempt in flight, or a reconnect already
152
+ * waiting out its backoff, is the attempt.
153
+ */
154
+ private ensureAttempting;
121
155
  private openSocket;
122
156
  private onOpen;
157
+ /**
158
+ * Abandons a socket and treats it as closed right now.
159
+ *
160
+ * A connection that died without a close frame can take minutes to report it, or
161
+ * never, so the close is synthesised rather than waited for. Bumping the generation
162
+ * means the real event, whenever it turns up, is ignored.
163
+ */
164
+ private dropSocket;
123
165
  private onClose;
166
+ /** Retries when it is allowed to, and tells everyone waiting when it is not. */
167
+ private retryOrGiveUp;
124
168
  /**
125
169
  * Reconnects with full jitter on top of any delay the server asked for.
126
170
  *
@@ -128,8 +172,27 @@ export declare class Link {
128
172
  * a fixed delay produces.
129
173
  */
130
174
  private scheduleReconnect;
175
+ private clearReconnect;
176
+ /**
177
+ * Pings on an interval and, the important half, notices when a ping goes unanswered.
178
+ *
179
+ * A websocket can die without a close frame — a dropped route, a proxy that forgets
180
+ * the connection, a suspended machine — leaving both ends convinced they are
181
+ * connected while every frame sent into it vanishes. An unanswered ping is the only
182
+ * evidence this end will ever get, so it is treated as a dead connection and
183
+ * reconnected rather than swallowed.
184
+ */
131
185
  private startHeartbeat;
186
+ /**
187
+ * Never longer than the interval itself: a second ping in flight tells us nothing new.
188
+ *
189
+ * Floored so that a very short interval cannot declare a merely busy connection dead.
190
+ */
191
+ private heartbeatTimeoutMs;
132
192
  private stopHeartbeat;
193
+ /** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
194
+ private wait;
195
+ private settleWaiters;
133
196
  private registerAll;
134
197
  private registerTools;
135
198
  private registerHook;
package/dist/link/link.js CHANGED
@@ -30,6 +30,21 @@ class Link {
30
30
  this.socket = null;
31
31
  this.frameCounter = 0;
32
32
  this.currentState = "idle";
33
+ /**
34
+ * Which socket the callbacks below belong to.
35
+ *
36
+ * Every handler carries the generation it was made for and does nothing once that is
37
+ * no longer current. Without it a late `close` from a socket we have already replaced
38
+ * tears down its successor — one blip turning into a link that flaps for the life of
39
+ * the process.
40
+ */
41
+ this.generation = 0;
42
+ /** True from the moment a socket is created until it is open or gone. */
43
+ this.attempting = false;
44
+ /** Settled per attempt: this is what `connect()` awaits. */
45
+ this.attemptWaiters = [];
46
+ /** Settled when the link opens, however many attempts that takes. `ready()` awaits these. */
47
+ this.openWaiters = [];
33
48
  this.reconnectAttempt = 0;
34
49
  this.reconnectAfterMs = 0;
35
50
  this.closedByUs = false;
@@ -107,58 +122,101 @@ class Link {
107
122
  // =============================================
108
123
  // LIFECYCLE
109
124
  // =============================================
110
- /** Connects, resolving once every tool and hook has been registered. */
125
+ /**
126
+ * Connects, resolving once every tool and hook has been registered.
127
+ *
128
+ * Rejects if *this* attempt fails. When `reconnect` is on the link keeps trying in
129
+ * the background regardless, so a caller can either await this again or just listen
130
+ * for the `connect` event.
131
+ */
111
132
  connect() {
112
133
  if (this.currentState === "open")
113
134
  return Promise.resolve(this);
114
- if (this.connecting)
115
- return this.connecting;
116
135
  this.closedByUs = false;
117
- this.connecting = this.openSocket().then(() => this, error => {
118
- this.connecting = undefined;
119
- throw error;
120
- });
121
- return this.connecting;
136
+ if (this.currentState === "closed")
137
+ this.currentState = "idle";
138
+ // Joins whatever attempt is already in flight — or already scheduled — rather than
139
+ // racing a second socket against it.
140
+ const waited = this.wait(this.attemptWaiters, 0);
141
+ this.ensureAttempting();
142
+ return waited.then(() => this);
122
143
  }
123
- /** Resolves when the link is usable, connecting first if it has not been asked to yet. */
144
+ /**
145
+ * Resolves when the link is usable, connecting first if it has not been asked to yet.
146
+ *
147
+ * Bounded by `requestTimeoutMs`, and it never opens a socket of its own while one is
148
+ * in flight: the callers are hook emits and tool replies, which are worth sending now
149
+ * or not at all. Waiting out a long outage here used to mean a new connection per
150
+ * event.
151
+ */
124
152
  async ready() {
125
153
  if (this.currentState === "open")
126
154
  return;
127
155
  if (this.currentState === "closed")
128
- throw new Error("This link has been closed.");
129
- await this.connect();
156
+ throw new protocol_1.LinkError("closed", "This link has been closed.");
157
+ const waited = this.wait(this.openWaiters, this.options.requestTimeoutMs);
158
+ this.ensureAttempting();
159
+ await waited;
130
160
  }
131
161
  /** Closes for good. Registrations are released server-side as the socket drops. */
132
162
  close(reason = "client closed") {
163
+ const closed = new protocol_1.LinkError("closed", "The link was closed.");
133
164
  this.closedByUs = true;
134
165
  this.currentState = "closed";
166
+ // Orphans the live socket's callbacks, so its close cannot reopen anything.
167
+ this.generation += 1;
168
+ this.attempting = false;
135
169
  this.stopHeartbeat();
136
- this.failPending(new protocol_1.LinkError("closed", "The link was closed."));
170
+ this.clearReconnect();
171
+ this.failPending(closed);
172
+ this.settleWaiters(this.attemptWaiters, closed);
173
+ this.settleWaiters(this.openWaiters, closed);
137
174
  this.socket?.close(1000, reason);
138
175
  this.socket = null;
139
- this.connecting = undefined;
140
- this.readySignal = undefined;
176
+ }
177
+ /**
178
+ * Starts an attempt, but only when there is nothing to join.
179
+ *
180
+ * The single door to opening a socket: an attempt in flight, or a reconnect already
181
+ * waiting out its backoff, is the attempt.
182
+ */
183
+ ensureAttempting() {
184
+ if (this.closedByUs || this.currentState === "open")
185
+ return;
186
+ if (this.attempting || this.reconnectTimer)
187
+ return;
188
+ this.openSocket();
141
189
  }
142
190
  openSocket() {
143
191
  this.currentState = "connecting";
144
- const signal = deferred();
145
- this.readySignal = signal;
192
+ this.attempting = true;
193
+ const generation = ++this.generation;
146
194
  const { url, protocols } = (0, socket_1.buildHandshake)(this.options.serverUrl, "link", this.options.apiKey);
147
195
  this.debug(`connecting to ${url.replace(/api_key=[^&]+/, "api_key=***")}`);
148
196
  try {
149
197
  this.socket = this.options.socketFactory(url, protocols, {
150
- onOpen: () => this.onOpen(),
151
- onMessage: (data) => this.onMessage(data),
152
- onClose: (code, reason) => this.onClose(code, reason),
153
- onError: (error) => this.emitter.emit("error", asError(error)),
198
+ onOpen: () => this.onOpen(generation),
199
+ onMessage: (data) => { if (generation === this.generation)
200
+ this.onMessage(data); },
201
+ onClose: (code, reason) => this.onClose(generation, code, reason),
202
+ onError: (error) => { if (generation === this.generation)
203
+ this.emitter.emit("error", asError(error)); },
154
204
  });
155
205
  }
156
206
  catch (error) {
157
- signal.reject(asError(error));
207
+ // A factory that throws never produces a close event, so this failure is
208
+ // reported and retried from here instead.
209
+ const failure = asError(error);
210
+ this.socket = null;
211
+ this.attempting = false;
212
+ this.emitter.emit("error", failure);
213
+ this.settleWaiters(this.attemptWaiters, failure);
214
+ this.retryOrGiveUp(failure);
158
215
  }
159
- return signal.promise;
160
216
  }
161
- onOpen() {
217
+ onOpen(generation) {
218
+ if (generation !== this.generation)
219
+ return;
162
220
  // The handshake declares who we are; nothing else may be sent before it.
163
221
  void this.exchange("hello", {
164
222
  linkId: this.options.linkId,
@@ -169,39 +227,88 @@ class Link {
169
227
  isDone: (frame) => frame.type === "welcome",
170
228
  }).then(async (frame) => {
171
229
  const welcome = frame.payload;
172
- this.identity = { connectionId: welcome.connectionId, scope: welcome.scope };
230
+ const identity = { connectionId: welcome.connectionId, scope: welcome.scope };
231
+ this.identity = identity;
173
232
  await this.registerAll();
233
+ // Registration is several round trips; the socket may have gone during them.
234
+ if (generation !== this.generation)
235
+ return;
236
+ this.attempting = false;
174
237
  this.currentState = "open";
175
238
  this.reconnectAttempt = 0;
176
239
  this.reconnectAfterMs = 0;
177
- this.startHeartbeat();
178
- this.readySignal?.resolve();
179
- this.emitter.emit("connect", this.identity);
240
+ this.startHeartbeat(generation);
241
+ this.settleWaiters(this.attemptWaiters);
242
+ this.settleWaiters(this.openWaiters);
243
+ this.emitter.emit("connect", identity);
180
244
  }).catch((error) => {
245
+ if (generation !== this.generation)
246
+ return;
181
247
  this.emitter.emit("error", error);
182
- // A rejected claim or an unsupported protocol will not fix itself by
183
- // trying again, so this stops rather than looping.
184
- this.closedByUs = true;
185
- this.readySignal?.reject(error);
186
- this.socket?.close(1000, "handshake failed");
248
+ this.settleWaiters(this.attemptWaiters, error);
249
+ // Only the server saying "do not come back" stops us: a rejected claim or an
250
+ // unsupported protocol will not fix itself. Everything else that can land here —
251
+ // a timeout, a socket dropped mid-registration, a transient server error — is
252
+ // precisely what reconnecting is for. Treating all of it as fatal left the link
253
+ // dead for the life of the process.
254
+ if (error instanceof protocol_1.LinkError && error.fatal)
255
+ this.closedByUs = true;
256
+ this.dropSocket(generation, 1000, "handshake failed");
187
257
  });
188
258
  }
189
- onClose(code, reason) {
259
+ /**
260
+ * Abandons a socket and treats it as closed right now.
261
+ *
262
+ * A connection that died without a close frame can take minutes to report it, or
263
+ * never, so the close is synthesised rather than waited for. Bumping the generation
264
+ * means the real event, whenever it turns up, is ignored.
265
+ */
266
+ dropSocket(generation, code, reason) {
267
+ if (generation !== this.generation)
268
+ return;
269
+ const socket = this.socket;
270
+ this.onClose(generation, code, reason);
271
+ try {
272
+ // 1006 is reserved and rejected by browsers; anything else we raise is a valid
273
+ // application code.
274
+ socket?.close(code === 1006 ? 1000 : code, reason);
275
+ }
276
+ catch {
277
+ // Already gone, which is the outcome we wanted anyway.
278
+ }
279
+ }
280
+ onClose(generation, code, reason) {
281
+ if (generation !== this.generation)
282
+ return;
283
+ // Whatever else this socket has to say is now somebody else's news.
284
+ this.generation += 1;
190
285
  const willReconnect = this.options.reconnect && !this.closedByUs;
286
+ const error = new protocol_1.LinkError("disconnected", `The link disconnected (${code}${reason ? `: ${reason}` : ""}).`);
191
287
  this.socket = null;
192
288
  this.identity = undefined;
193
- this.connecting = undefined;
289
+ this.attempting = false;
194
290
  this.currentState = willReconnect ? "connecting" : "closed";
195
291
  // Nothing about subscriptions survives a socket: the server re-sends the set on every
196
292
  // connect, so holding the old one would only risk reporting against ids that are gone.
197
293
  this.subscriptionStore.reset();
198
294
  this.stopHeartbeat();
199
- this.failPending(new protocol_1.LinkError("disconnected", `The link disconnected (${code}${reason ? `: ${reason}` : ""}).`));
295
+ this.failPending(error);
296
+ this.settleWaiters(this.attemptWaiters, error);
200
297
  this.emitter.emit("disconnect", { code, reason, willReconnect });
201
- this.readySignal?.reject(new protocol_1.LinkError("disconnected", `The link disconnected (${code}).`));
202
- this.readySignal = undefined;
203
298
  if (willReconnect)
204
299
  this.scheduleReconnect();
300
+ else
301
+ this.settleWaiters(this.openWaiters, error);
302
+ }
303
+ /** Retries when it is allowed to, and tells everyone waiting when it is not. */
304
+ retryOrGiveUp(error) {
305
+ if (this.options.reconnect && !this.closedByUs) {
306
+ this.currentState = "connecting";
307
+ this.scheduleReconnect();
308
+ return;
309
+ }
310
+ this.currentState = "closed";
311
+ this.settleWaiters(this.openWaiters, error);
205
312
  }
206
313
  /**
207
314
  * Reconnects with full jitter on top of any delay the server asked for.
@@ -210,33 +317,97 @@ class Link {
210
317
  * a fixed delay produces.
211
318
  */
212
319
  scheduleReconnect() {
320
+ this.clearReconnect();
213
321
  const ceiling = Math.min(this.options.maxReconnectDelayMs, this.options.minReconnectDelayMs * 2 ** this.reconnectAttempt);
214
322
  const delay = this.reconnectAfterMs + Math.random() * ceiling;
215
323
  this.reconnectAttempt += 1;
216
324
  this.debug(`reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempt})`);
217
- const timer = setTimeout(() => {
218
- if (this.closedByUs)
325
+ this.reconnectTimer = setTimeout(() => {
326
+ this.reconnectTimer = undefined;
327
+ if (this.closedByUs || this.currentState === "open" || this.attempting)
219
328
  return;
220
- this.openSocket().catch(error => this.emitter.emit("error", asError(error)));
329
+ this.openSocket();
221
330
  }, delay);
222
- unref(timer);
331
+ unref(this.reconnectTimer);
332
+ }
333
+ clearReconnect() {
334
+ if (this.reconnectTimer)
335
+ clearTimeout(this.reconnectTimer);
336
+ this.reconnectTimer = undefined;
223
337
  }
224
- startHeartbeat() {
338
+ /**
339
+ * Pings on an interval and, the important half, notices when a ping goes unanswered.
340
+ *
341
+ * A websocket can die without a close frame — a dropped route, a proxy that forgets
342
+ * the connection, a suspended machine — leaving both ends convinced they are
343
+ * connected while every frame sent into it vanishes. An unanswered ping is the only
344
+ * evidence this end will ever get, so it is treated as a dead connection and
345
+ * reconnected rather than swallowed.
346
+ */
347
+ startHeartbeat(generation) {
225
348
  if (!this.options.heartbeatMs)
226
349
  return;
350
+ this.stopHeartbeat();
227
351
  this.heartbeat = setInterval(() => {
352
+ if (generation !== this.generation)
353
+ return;
228
354
  // The reply is consumed by the exchange, so it never reaches log listeners.
229
- this.exchange("ping", {}, { isDone: (frame) => frame.type === "log" })
230
- .catch(() => undefined);
355
+ this.exchange("ping", {}, {
356
+ awaitReady: false,
357
+ isDone: (frame) => frame.type === "log",
358
+ timeoutMs: this.heartbeatTimeoutMs(),
359
+ }).catch(() => {
360
+ if (generation !== this.generation)
361
+ return;
362
+ this.debug("heartbeat went unanswered, treating the connection as dead");
363
+ this.dropSocket(generation, 4000, "heartbeat timeout");
364
+ });
231
365
  }, this.options.heartbeatMs);
232
366
  unref(this.heartbeat);
233
367
  }
368
+ /**
369
+ * Never longer than the interval itself: a second ping in flight tells us nothing new.
370
+ *
371
+ * Floored so that a very short interval cannot declare a merely busy connection dead.
372
+ */
373
+ heartbeatTimeoutMs() {
374
+ return Math.max(250, Math.min(this.options.requestTimeoutMs, this.options.heartbeatMs));
375
+ }
234
376
  stopHeartbeat() {
235
377
  if (this.heartbeat)
236
378
  clearInterval(this.heartbeat);
237
379
  this.heartbeat = undefined;
238
380
  }
239
381
  // =============================================
382
+ // WAITING
383
+ // =============================================
384
+ /** Parks a caller until someone settles the list it was parked in. 0 waits forever. */
385
+ wait(waiters, timeoutMs) {
386
+ return new Promise((resolve, reject) => {
387
+ const waiter = { resolve, reject };
388
+ if (timeoutMs > 0) {
389
+ waiter.timer = setTimeout(() => {
390
+ const index = waiters.indexOf(waiter);
391
+ if (index >= 0)
392
+ waiters.splice(index, 1);
393
+ reject(new protocol_1.LinkError("timeout", `The link was not open within ${timeoutMs}ms.`));
394
+ }, timeoutMs);
395
+ unref(waiter.timer);
396
+ }
397
+ waiters.push(waiter);
398
+ });
399
+ }
400
+ settleWaiters(waiters, error) {
401
+ for (const waiter of waiters.splice(0, waiters.length)) {
402
+ if (waiter.timer)
403
+ clearTimeout(waiter.timer);
404
+ if (error)
405
+ waiter.reject(error);
406
+ else
407
+ waiter.resolve();
408
+ }
409
+ }
410
+ // =============================================
240
411
  // REGISTRATION
241
412
  // =============================================
242
413
  async registerAll() {
@@ -269,7 +440,13 @@ class Link {
269
440
  * that was valid a moment ago is a race, not a bug worth complaining about.
270
441
  */
271
442
  async reportHookEvent(hookId, event, payload, chosenIds) {
272
- await this.ready();
443
+ // Not `ready()`: a report is only worth anything now, and while the link is down the
444
+ // subscription set is empty anyway — so waiting would mean holding a busy guild's
445
+ // events open to match them against nothing.
446
+ if (this.currentState !== "open") {
447
+ this.debug(`dropped ${event}: the link is not connected`);
448
+ return [];
449
+ }
273
450
  const sourceId = this.hooks.get(hookId)?.sourceId ?? hookId;
274
451
  // Explicit ids are still checked against what this link actually holds. Not out of distrust of
275
452
  // the caller — the server checks again anyway — but because an id it was never given can only
@@ -490,15 +667,6 @@ exports.Link = Link;
490
667
  // =============================================
491
668
  // HELPERS
492
669
  // =============================================
493
- function deferred() {
494
- let resolve;
495
- let reject;
496
- const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
497
- // Rejections are surfaced through connect() and the error event; an unobserved
498
- // one here must not take the process down.
499
- promise.catch(() => undefined);
500
- return { promise, resolve, reject };
501
- }
502
670
  function asError(value) {
503
671
  if (value instanceof Error)
504
672
  return value;
@@ -21,6 +21,7 @@ export type LinkToolDescriptor = {
21
21
  longDescription: string;
22
22
  };
23
23
  defaultEnabled?: boolean;
24
+ platforms?: string[];
24
25
  timeoutMs?: number;
25
26
  };
26
27
  export type LinkHookEventDeclaration = {
@@ -64,6 +64,19 @@ export type ToolConfig<S extends ToolSchema | undefined> = {
64
64
  };
65
65
  /** Whether the tool is on before the user has touched it. */
66
66
  defaultEnabled?: boolean;
67
+ /**
68
+ * Where in Alfred the tool is reachable, as platform ids. Defaults to the user's chat.
69
+ *
70
+ * Say this when your client mirrors a whole platform. A Discord bot offering seventy tools does
71
+ * not want seventy entries in front of somebody having a conversation about their groceries — it
72
+ * wants them behind Alfred's Discord agent, which is one entry and already knows how to decide
73
+ * when Discord is relevant.
74
+ *
75
+ * Known values are Alfred's platform ids, e.g. `"platform.chat.user"` (the default) and
76
+ * `"platform.agent.discord"`. An unknown one is rejected at registration rather than ignored,
77
+ * because a tool reachable from nowhere is indistinguishable from a tool that is broken.
78
+ */
79
+ platforms?: string[];
67
80
  /** How long the server waits for a result before giving up. */
68
81
  timeoutMs?: number;
69
82
  /** Runs the tool. Return anything JSON-serialisable, or throw to fail the call. */
package/dist/link/tool.js CHANGED
@@ -27,6 +27,7 @@ class Tool {
27
27
  inputSchema,
28
28
  ...(this.config.display ? { display: this.config.display } : {}),
29
29
  ...(this.config.defaultEnabled !== undefined ? { defaultEnabled: this.config.defaultEnabled } : {}),
30
+ ...(this.config.platforms ? { platforms: this.config.platforms } : {}),
30
31
  ...(this.config.timeoutMs !== undefined ? { timeoutMs: this.config.timeoutMs } : {}),
31
32
  };
32
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -156,6 +156,27 @@ Subscriptions are never persisted by the SDK. They arrive on connect, follow del
156
156
  connected, and are dropped on disconnect — so there is nothing to reconcile, and a restart is
157
157
  correct by construction.
158
158
 
159
+ ### Tools can belong to an agent instead of a chat
160
+
161
+ By default a tool shows up where a user is talking to Alfred. If your client mirrors a whole
162
+ platform, say so instead:
163
+
164
+ ```ts
165
+ new Tool({
166
+ id: "discord_member_kick",
167
+ description: "Kick a member from a server.",
168
+ platforms: ["platform.agent.discord"],
169
+ run: async ({ args, meta }) => kick(meta.identities?.discord, args),
170
+ });
171
+ ```
172
+
173
+ Seventy tools in front of somebody asking about their groceries is not a feature. Behind Alfred's
174
+ Discord agent they are one entry that already knows when Discord is relevant — and the agent keeps
175
+ whatever tier and permission gating it carries, which tools bolted onto the chat would quietly skip.
176
+
177
+ An unknown platform is rejected when you register, not ignored: a tool reachable from nowhere looks
178
+ exactly like a tool that is broken.
179
+
159
180
  ### Tools belong to the user, not to a conversation
160
181
 
161
182
  Once a tool is registered, Alfred can call it anywhere that user talks to it — the web