@butlerbot/sdk 0.0.31 → 0.0.33

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.
@@ -0,0 +1,103 @@
1
+ import { LinkAgentDescriptor, LinkServerFrame, LinkServerFrameOf } from "./protocol";
2
+ import { AnyTool } from "./tool";
3
+ export type AgentConfig = {
4
+ /**
5
+ * This agent's id within the link. The public id becomes `link:<linkId>/<id>`,
6
+ * which is what the user's saved settings refer to — so treat it as permanent.
7
+ */
8
+ id: string;
9
+ /** The agent's own name. It signs its replies with it. */
10
+ name: string;
11
+ /** What Alfred reads when deciding whether to hand something to this agent. */
12
+ description: string;
13
+ /**
14
+ * The agent's system prompt: who it is, what its tools are for, and how they go together.
15
+ * This is where a thousand tools become one coherent worker.
16
+ */
17
+ prompt: string;
18
+ /**
19
+ * The model the agent runs on, by its catalogue name. Omit it for the user's default.
20
+ * A model the user's plan does not include falls back to their default, exactly as a chat would.
21
+ */
22
+ model?: string;
23
+ /**
24
+ * The tools the agent works with. They belong to the agent: Alfred never sees them
25
+ * directly, and they need no `addTool` of their own.
26
+ */
27
+ tools?: AnyTool[];
28
+ /** Shown in Alfred's settings UI. Without it the agent is hidden there. */
29
+ display?: {
30
+ name: string;
31
+ shortDescription: string;
32
+ longDescription: string;
33
+ };
34
+ /** Whether the agent is on before the user has touched it. */
35
+ defaultEnabled?: boolean;
36
+ };
37
+ /** Progress on an exchange, in the words the agent's status feed would show a conversation. */
38
+ export type AgentStatus = {
39
+ label: string;
40
+ state: "running" | "completed" | "failed";
41
+ };
42
+ export type AgentChatOptions = {
43
+ /**
44
+ * The exchange this message continues. Messages on one thread share memory.
45
+ *
46
+ * Defaults to the agent's own thread, which is minted when the `Agent` is created — so one
47
+ * `Agent` remembers across `chat` calls, and a new process starts afresh. Name one yourself
48
+ * to pick a conversation up across restarts, or to keep several going at once.
49
+ */
50
+ thread?: string;
51
+ /** Called with each status update while the agent works. */
52
+ onStatus?: (status: AgentStatus) => void;
53
+ };
54
+ export type AgentReply = {
55
+ /** What the agent said. */
56
+ text: string;
57
+ /** The thread the reply belongs to, which is what continues it. */
58
+ thread: string;
59
+ };
60
+ /** What an agent needs from its link. Implemented by `Link`. */
61
+ export type AgentRunner = {
62
+ chatAgent(agentId: string, message: string, options: AgentChatOptions): Promise<AgentReply>;
63
+ };
64
+ /**
65
+ * An AI worker that lives on the server for as long as the link does, working with tools
66
+ * that run here.
67
+ *
68
+ * To Alfred it is one tool: he hands it a task and gets a reply, and the tools behind it
69
+ * stay behind it. To your code it is something to talk to directly — `chat` runs it on the
70
+ * server and hands the answer back here, so a hook callback can ask it to decide something
71
+ * and act on what it says.
72
+ */
73
+ export declare class Agent {
74
+ private readonly config;
75
+ readonly id: string;
76
+ readonly tools: AnyTool[];
77
+ /** The public id (`link:<linkId>/<id>`), known once the link has registered it. */
78
+ linkedId?: string;
79
+ /** The thread `chat` uses when not told otherwise. One per `Agent`, so it remembers. */
80
+ thread: string;
81
+ private link?;
82
+ constructor(config: AgentConfig);
83
+ get name(): string;
84
+ get description(): string;
85
+ /** The declaration sent to the server. */
86
+ descriptor(): LinkAgentDescriptor;
87
+ /** Called by `Link.addAgent`. */
88
+ attach(link: AgentRunner): void;
89
+ /** One of this agent's tools, by its id. */
90
+ getTool(id: string): AnyTool | undefined;
91
+ /**
92
+ * Asks the agent something and waits for its reply.
93
+ *
94
+ * The run happens on the server, on the user's account; the question and the answer live
95
+ * here. Rejects with a `LinkError` when the agent could not run or failed — never with the
96
+ * agent's own prose, which is a reply like any other.
97
+ */
98
+ chat(message: string, options?: AgentChatOptions): Promise<AgentReply>;
99
+ /** Starts a fresh thread for the calls that follow, and returns it. */
100
+ newThread(): string;
101
+ }
102
+ /** Whether a server frame ends an `agent.chat` exchange. */
103
+ export declare function isAgentResult(frame: LinkServerFrame): frame is LinkServerFrameOf<"agent.result">;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Agent = void 0;
4
+ exports.isAgentResult = isAgentResult;
5
+ /**
6
+ * An AI worker that lives on the server for as long as the link does, working with tools
7
+ * that run here.
8
+ *
9
+ * To Alfred it is one tool: he hands it a task and gets a reply, and the tools behind it
10
+ * stay behind it. To your code it is something to talk to directly — `chat` runs it on the
11
+ * server and hands the answer back here, so a hook callback can ask it to decide something
12
+ * and act on what it says.
13
+ */
14
+ class Agent {
15
+ constructor(config) {
16
+ this.config = config;
17
+ this.id = config.id;
18
+ this.tools = [...(config.tools ?? [])];
19
+ this.thread = mintThread();
20
+ const ids = new Set();
21
+ for (const tool of this.tools) {
22
+ if (ids.has(tool.id))
23
+ throw new Error(`Agent "${this.id}" holds two tools with the id "${tool.id}".`);
24
+ ids.add(tool.id);
25
+ }
26
+ }
27
+ get name() {
28
+ return this.config.name;
29
+ }
30
+ get description() {
31
+ return this.config.description;
32
+ }
33
+ /** The declaration sent to the server. */
34
+ descriptor() {
35
+ return {
36
+ localId: this.id,
37
+ name: this.config.name,
38
+ description: this.config.description,
39
+ prompt: this.config.prompt,
40
+ ...(this.config.model ? { model: this.config.model } : {}),
41
+ // An agent's tools are placed by the agent, so whatever platform a tool
42
+ // declared for itself does not travel.
43
+ tools: this.tools.map(tool => {
44
+ const { platforms: _platforms, ...descriptor } = tool.descriptor();
45
+ return descriptor;
46
+ }),
47
+ ...(this.config.display ? { display: this.config.display } : {}),
48
+ ...(this.config.defaultEnabled !== undefined ? { defaultEnabled: this.config.defaultEnabled } : {}),
49
+ };
50
+ }
51
+ /** Called by `Link.addAgent`. */
52
+ attach(link) {
53
+ this.link = link;
54
+ }
55
+ /** One of this agent's tools, by its id. */
56
+ getTool(id) {
57
+ return this.tools.find(tool => tool.id === id);
58
+ }
59
+ /**
60
+ * Asks the agent something and waits for its reply.
61
+ *
62
+ * The run happens on the server, on the user's account; the question and the answer live
63
+ * here. Rejects with a `LinkError` when the agent could not run or failed — never with the
64
+ * agent's own prose, which is a reply like any other.
65
+ */
66
+ chat(message, options = {}) {
67
+ if (!this.link)
68
+ return Promise.reject(new Error(`Agent "${this.id}" has not been added to a link.`));
69
+ return this.link.chatAgent(this.id, message, { thread: this.thread, ...options });
70
+ }
71
+ /** Starts a fresh thread for the calls that follow, and returns it. */
72
+ newThread() {
73
+ this.thread = mintThread();
74
+ return this.thread;
75
+ }
76
+ }
77
+ exports.Agent = Agent;
78
+ /** A thread id the server will accept: letters, digits, dot, dash, underscore, 64 at most. */
79
+ function mintThread() {
80
+ const random = Math.random().toString(36).slice(2, 10);
81
+ return `t-${Date.now().toString(36)}-${random}`;
82
+ }
83
+ /** Whether a server frame ends an `agent.chat` exchange. */
84
+ function isAgentResult(frame) {
85
+ return frame.type === "agent.result";
86
+ }
@@ -2,10 +2,12 @@ export { Link } from "./link";
2
2
  export type { LinkOptions, LinkEvents, LinkState, ExchangeOptions } from "./link";
3
3
  export { Tool } from "./tool";
4
4
  export type { AnyTool, ToolConfig, ToolRunContext, ToolCallMeta, ToolStatusReporter, ToolInvocation } from "./tool";
5
+ export { Agent } from "./agent";
6
+ export type { AgentConfig, AgentChatOptions, AgentReply, AgentStatus, AgentRunner } from "./agent";
5
7
  export { Hook } from "./hook";
6
8
  export type { AnyHook, HookConfig, HookEmitter } from "./hook";
7
9
  export { LINK_PROTOCOL_VERSION, LinkError } from "./protocol";
8
- export type { LinkClientFrame, LinkClientFrameOf, LinkClientFrameType, LinkServerFrame, LinkServerFrameOf, LinkServerFrameType, LinkScopeKind, LinkToolDescriptor, LinkHookDeclaration, LinkHookEventDeclaration, } from "./protocol";
10
+ export type { LinkClientFrame, LinkClientFrameOf, LinkClientFrameType, LinkServerFrame, LinkServerFrameOf, LinkServerFrameType, LinkScopeKind, LinkToolDescriptor, LinkAgentDescriptor, LinkHookDeclaration, LinkHookEventDeclaration, } from "./protocol";
9
11
  export { SubscriptionStore } from "./subscriptions";
10
12
  export type { LinkSubscription, SubscriptionSnapshot, SubscriptionDelta } from "./subscriptions";
11
13
  export { matchesPrefilter, readPath } from "./prefilter";
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.defaultSocketFactory = exports.buildHandshake = exports.readPath = exports.matchesPrefilter = exports.SubscriptionStore = exports.LinkError = exports.LINK_PROTOCOL_VERSION = exports.Hook = exports.Tool = exports.Link = void 0;
3
+ exports.defaultSocketFactory = exports.buildHandshake = exports.readPath = exports.matchesPrefilter = exports.SubscriptionStore = exports.LinkError = exports.LINK_PROTOCOL_VERSION = exports.Hook = exports.Agent = exports.Tool = exports.Link = void 0;
4
4
  var link_1 = require("./link");
5
5
  Object.defineProperty(exports, "Link", { enumerable: true, get: function () { return link_1.Link; } });
6
6
  var tool_1 = require("./tool");
7
7
  Object.defineProperty(exports, "Tool", { enumerable: true, get: function () { return tool_1.Tool; } });
8
+ var agent_1 = require("./agent");
9
+ Object.defineProperty(exports, "Agent", { enumerable: true, get: function () { return agent_1.Agent; } });
8
10
  var hook_1 = require("./hook");
9
11
  Object.defineProperty(exports, "Hook", { enumerable: true, get: function () { return hook_1.Hook; } });
10
12
  var protocol_1 = require("./protocol");
@@ -1,3 +1,4 @@
1
+ import { Agent, AgentChatOptions, AgentReply, AgentRunner } from "./agent";
1
2
  import { AnyHook } from "./hook";
2
3
  import { LinkClientFrameType, LinkClientPayloads, LinkScopeKind, LinkServerFrame } from "./protocol";
3
4
  import { SocketFactory } from "./socket";
@@ -83,10 +84,11 @@ export type ExchangeOptions = {
83
84
  * re-declared on connect, and ids are derived from your `linkId`, so a reconnect
84
85
  * anywhere lands on the same saved settings and subscriptions.
85
86
  */
86
- export declare class Link {
87
+ export declare class Link implements AgentRunner {
87
88
  private readonly options;
88
89
  private readonly emitter;
89
90
  private readonly tools;
91
+ private readonly agents;
90
92
  private readonly hooks;
91
93
  private readonly subscriptionStore;
92
94
  private readonly pending;
@@ -129,10 +131,22 @@ export declare class Link {
129
131
  constructor(options: LinkOptions);
130
132
  /** Adds a tool Alfred can call. Registered on connect, or immediately if already open. */
131
133
  addTool(tool: AnyTool): this;
134
+ /**
135
+ * Adds an agent, with the tools it works with.
136
+ *
137
+ * The tools come with the agent — they need no `addTool` of their own, and giving them one
138
+ * would place them in the chat as well, which is the thing an agent exists to avoid. One
139
+ * namespace for everything the link declares, so an id used twice is refused here rather
140
+ * than resolved by whichever registered last.
141
+ */
142
+ addAgent(agent: Agent): this;
132
143
  /** Adds a hook that can wake the user's background agents. */
133
144
  addHook(hook: AnyHook): this;
134
145
  getTool(id: string): AnyTool | undefined;
135
146
  getHook(id: string): AnyHook | undefined;
147
+ getAgent(id: string): Agent | undefined;
148
+ /** A tool by its local id, wherever it lives: on the link itself or behind one of its agents. */
149
+ private findTool;
136
150
  get state(): LinkState;
137
151
  get linkId(): string;
138
152
  /** The ephemeral id of this connection. Changes on every reconnect. */
@@ -244,6 +258,15 @@ export declare class Link {
244
258
  private wait;
245
259
  private settleWaiters;
246
260
  private registerAll;
261
+ private registerAgents;
262
+ /**
263
+ * Called by `Agent.chat`.
264
+ *
265
+ * One exchange, however long the agent takes: status frames go to the caller as they
266
+ * arrive, and the result frame ends it. Never timed out here — an agent that is calling
267
+ * tools on this very machine may legitimately take a while.
268
+ */
269
+ chatAgent(agentId: string, message: string, options: AgentChatOptions): Promise<AgentReply>;
247
270
  private registerTools;
248
271
  private registerHook;
249
272
  /**
package/dist/link/link.js CHANGED
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Link = void 0;
4
4
  const config_1 = require("../config");
5
5
  const emitter_1 = require("../util/emitter");
6
+ const agent_1 = require("./agent");
6
7
  const protocol_1 = require("./protocol");
7
8
  const socket_1 = require("./socket");
8
9
  const subscriptions_1 = require("./subscriptions");
@@ -23,6 +24,7 @@ class Link {
23
24
  constructor(options) {
24
25
  this.emitter = new emitter_1.Emitter();
25
26
  this.tools = new Map();
27
+ this.agents = new Map();
26
28
  this.hooks = new Map();
27
29
  this.subscriptionStore = new subscriptions_1.SubscriptionStore();
28
30
  this.pending = new Map();
@@ -93,6 +95,29 @@ class Link {
93
95
  void this.registerTools([tool]);
94
96
  return this;
95
97
  }
98
+ /**
99
+ * Adds an agent, with the tools it works with.
100
+ *
101
+ * The tools come with the agent — they need no `addTool` of their own, and giving them one
102
+ * would place them in the chat as well, which is the thing an agent exists to avoid. One
103
+ * namespace for everything the link declares, so an id used twice is refused here rather
104
+ * than resolved by whichever registered last.
105
+ */
106
+ addAgent(agent) {
107
+ for (const tool of agent.tools) {
108
+ const holder = this.findTool(tool.id) ? "another tool" : this.agents.has(tool.id) ? "an agent" : undefined;
109
+ if (holder)
110
+ throw new Error(`Cannot add agent "${agent.id}": its tool "${tool.id}" shares an id with ${holder} on this link.`);
111
+ }
112
+ if (this.tools.has(agent.id) || this.findTool(agent.id)) {
113
+ throw new Error(`Cannot add agent "${agent.id}": a tool on this link already has that id.`);
114
+ }
115
+ this.agents.set(agent.id, agent);
116
+ agent.attach(this);
117
+ if (this.currentState === "open")
118
+ void this.registerAgents([agent]);
119
+ return this;
120
+ }
96
121
  /** Adds a hook that can wake the user's background agents. */
97
122
  addHook(hook) {
98
123
  this.hooks.set(hook.id, hook);
@@ -107,6 +132,21 @@ class Link {
107
132
  getHook(id) {
108
133
  return this.hooks.get(id);
109
134
  }
135
+ getAgent(id) {
136
+ return this.agents.get(id);
137
+ }
138
+ /** A tool by its local id, wherever it lives: on the link itself or behind one of its agents. */
139
+ findTool(localId) {
140
+ const own = this.tools.get(localId);
141
+ if (own)
142
+ return own;
143
+ for (const agent of this.agents.values()) {
144
+ const tool = agent.getTool(localId);
145
+ if (tool)
146
+ return tool;
147
+ }
148
+ return undefined;
149
+ }
110
150
  // =============================================
111
151
  // STATE
112
152
  // =============================================
@@ -516,9 +556,45 @@ class Link {
516
556
  // =============================================
517
557
  async registerAll() {
518
558
  await this.registerTools(Array.from(this.tools.values()));
559
+ await this.registerAgents(Array.from(this.agents.values()));
519
560
  for (const hook of this.hooks.values())
520
561
  await this.registerHook(hook);
521
562
  }
563
+ async registerAgents(agents) {
564
+ if (!agents.length)
565
+ return;
566
+ const frame = await this.exchange("agent.register", { agents: agents.map(agent => agent.descriptor()) }, { awaitReady: false });
567
+ const ids = frame.payload.ids ?? [];
568
+ agents.forEach((agent, index) => { agent.linkedId = ids[index]; });
569
+ this.debug(`registered ${agents.length} agent(s)`);
570
+ }
571
+ /**
572
+ * Called by `Agent.chat`.
573
+ *
574
+ * One exchange, however long the agent takes: status frames go to the caller as they
575
+ * arrive, and the result frame ends it. Never timed out here — an agent that is calling
576
+ * tools on this very machine may legitimately take a while.
577
+ */
578
+ async chatAgent(agentId, message, options) {
579
+ const frame = await this.exchange("agent.chat", {
580
+ localId: agentId,
581
+ message,
582
+ ...(options.thread ? { thread: options.thread } : {}),
583
+ }, {
584
+ timeoutMs: 0,
585
+ isDone: agent_1.isAgentResult,
586
+ onFrame: (update) => {
587
+ if (update.type === "agent.status")
588
+ options.onStatus?.({ label: update.payload.label, state: update.payload.state });
589
+ },
590
+ });
591
+ if (!(0, agent_1.isAgentResult)(frame))
592
+ throw new protocol_1.LinkError("bad_frame", `Expected an agent.result, got "${frame.type}".`);
593
+ const { payload } = frame;
594
+ if (!payload.ok)
595
+ throw new protocol_1.LinkError(payload.code, payload.error);
596
+ return { text: payload.output, thread: payload.thread };
597
+ }
522
598
  async registerTools(tools) {
523
599
  if (!tools.length)
524
600
  return;
@@ -725,7 +801,7 @@ class Link {
725
801
  }
726
802
  handleToolCall(frame) {
727
803
  const { callId, localId, args, meta } = frame.payload;
728
- const tool = this.tools.get(localId);
804
+ const tool = this.findTool(localId);
729
805
  if (!tool) {
730
806
  this.send("tool.result", { ok: false, error: `This link has no tool "${localId}".` }, frame.id);
731
807
  return;
@@ -24,6 +24,22 @@ export type LinkToolDescriptor = {
24
24
  platforms?: string[];
25
25
  timeoutMs?: number;
26
26
  };
27
+ /**
28
+ * An agent as declared to the server: a prompt, a model and the tools it works with.
29
+ *
30
+ * The tools travel inside the agent because that is what places them: reachable through the
31
+ * agent only, never from a chat directly.
32
+ */
33
+ export type LinkAgentDescriptor = {
34
+ localId: string;
35
+ name: string;
36
+ description: string;
37
+ prompt: string;
38
+ model?: string;
39
+ tools: Omit<LinkToolDescriptor, "platforms">[];
40
+ display?: LinkToolDescriptor["display"];
41
+ defaultEnabled?: boolean;
42
+ };
27
43
  export type LinkHookEventDeclaration = {
28
44
  name: string;
29
45
  description?: string;
@@ -139,6 +155,18 @@ export type LinkClientPayloads = {
139
155
  chatId: string;
140
156
  message: string;
141
157
  };
158
+ "agent.register": {
159
+ agents: LinkAgentDescriptor[];
160
+ };
161
+ /**
162
+ * Talks to one of this link's agents directly. `thread` names the exchange the message
163
+ * continues; the same thread carries the same memory.
164
+ */
165
+ "agent.chat": {
166
+ localId: string;
167
+ message: string;
168
+ thread?: string;
169
+ };
142
170
  };
143
171
  export type LinkClientFrameType = keyof LinkClientPayloads;
144
172
  /**
@@ -230,6 +258,25 @@ export type LinkServerPayloads = {
230
258
  reason: string;
231
259
  reconnectAfterMs: number;
232
260
  };
261
+ /** Progress on an `agent.chat`, in the words the agent's status feed would show a conversation. */
262
+ "agent.status": {
263
+ localId: string;
264
+ label: string;
265
+ state: "running" | "completed" | "failed";
266
+ };
267
+ /** The agent's reply, exactly once per `agent.chat`. */
268
+ "agent.result": {
269
+ localId: string;
270
+ thread: string;
271
+ ok: true;
272
+ output: string;
273
+ } | {
274
+ localId: string;
275
+ thread?: string;
276
+ ok: false;
277
+ code: string;
278
+ error: string;
279
+ };
233
280
  /**
234
281
  * The full set this connection should watch, for the sources it has registered.
235
282
  *
@@ -207,6 +207,16 @@ export type ResponseStatusPayload = {
207
207
  /** When the stop was requested. */
208
208
  at: number;
209
209
  };
210
+ /**
211
+ * Messages steered into the turn that never reached the model, in the order they were sent.
212
+ *
213
+ * Steering only lands at a step boundary, and a turn does not always have another one: it
214
+ * stops, it fails, or the model answers without needing a further step. The text is not in
215
+ * the conversation's history — it was never said to anyone — so it is handed back here and
216
+ * the client decides what to do with it. Send it as an ordinary message when the turn
217
+ * merely ended; put it back in front of the user when they are the one who stopped it.
218
+ */
219
+ undeliveredSteer?: string[];
210
220
  /** Rich response metadata — only present on the final `completed: true` event.
211
221
  * Populated by the caller (e.g. gateway) after the response finishes and usage is available. */
212
222
  metadata?: ResponseMetadata;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@butlerbot/sdk",
3
- "version": "0.0.31",
3
+ "version": "0.0.33",
4
4
  "description": "The official ButlerBot SDK",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/readme.md CHANGED
@@ -212,6 +212,45 @@ whatever tier and permission gating it carries, which tools bolted onto the chat
212
212
  An unknown platform is rejected when you register, not ignored: a tool reachable from nowhere looks
213
213
  exactly like a tool that is broken.
214
214
 
215
+ ### Agents: your tools behind a worker of your own
216
+
217
+ When your client has more tools than a chat should see, or tools that only make sense together,
218
+ declare an agent. It lives on the server for as long as the link does, works with tools that run
219
+ here, and to Alfred it is one tool:
220
+
221
+ ```ts
222
+ import { Agent, Tool } from "@butlerbot/sdk";
223
+
224
+ const grind = new Tool({ id: "grind", description: "Grind beans.", run: async () => grinder.run() });
225
+ const brew = new Tool({ id: "brew", description: "Brew from the ground beans.", run: async () => machine.brew() });
226
+
227
+ const barista = new Agent({
228
+ id: "barista",
229
+ name: "Barista",
230
+ description: "Runs the kitchen coffee machine: grinding, brewing, cleaning.",
231
+ prompt: "You operate a coffee machine. Always grind before you brew. Report what you made.",
232
+ model: "DeepSeek-V4-Flash",
233
+ tools: [grind, brew],
234
+ });
235
+ link.addAgent(barista);
236
+ ```
237
+
238
+ The tools come with the agent — no `addTool` for them, and Alfred never sees them directly. The
239
+ prompt is where a thousand tools become one coherent worker.
240
+
241
+ You can talk to the agent yourself. The run happens on the server, on your account; the question
242
+ and the answer live here:
243
+
244
+ ```ts
245
+ const { text } = await barista.chat("Make me a flat white.");
246
+ ```
247
+
248
+ One `Agent` remembers across `chat` calls; pass `{ thread }` to name the conversation yourself, or
249
+ `newThread()` to start over. This is what makes a hook callback useful on its own: something
250
+ happens, your code notices, and you ask an agent what to do about it.
251
+
252
+ Needs `link.tools.register` to declare agents, and `tools.run` to talk to one directly.
253
+
215
254
  ### Tools belong to the user, not to a conversation
216
255
 
217
256
  Once a tool is registered, Alfred can call it anywhere that user talks to it — the web