@localhostdevs/sdk 0.17.0 → 0.19.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/README.md CHANGED
@@ -1,356 +1,383 @@
1
- # @localhostdevs/sdk
2
-
3
- The JavaScript SDK for building bots on **[localhostdevs](https://localhostdevs.com)** — run your app on your own laptop and publish it as a command-driven bot that anyone can use through a chat-style interface.
4
-
5
- > Pre-1.0: API surface may break in minor versions (`0.x → 0.y`). Once the platform launches with external users, we'll cut a 1.0 that promises stability.
6
-
7
- ## Install
8
-
9
- ```bash
10
- npm install @localhostdevs/sdk
11
- ```
12
-
13
- ## Quickstart
14
-
15
- After creating a bot in the [dashboard](https://localhostdevs.com/dashboard/bots) and copying your API key, write this to `app.js`:
16
-
17
- ```js
18
- import { Bot } from '@localhostdevs/sdk';
19
-
20
- const bot = new Bot({
21
- id: 'price-bot', // your bot's handle (matches the @handle on the marketplace)
22
- // apiKey: process.env.BOT_SECRET, // optional; auto-read from env if unset
23
- });
24
-
25
- bot.cmd({ name: 'ping' }, async (ctx) => {
26
- await ctx.reply({ text: 'pong' });
27
- });
28
-
29
- bot.cmd({ name: 'echo' }, async (ctx) => {
30
- await ctx.reply({ text: `echo: ${JSON.stringify(ctx.args)}` });
31
- });
32
-
33
- await bot.connect();
34
- console.log('bot online — waiting for commands');
35
- ```
36
-
37
- Then:
38
-
39
- ```bash
40
- BOT_SECRET=<your Bot Secret from the dashboard> node app.js
41
- ```
42
-
43
- The bot stays online for as long as the process runs. The gateway keeps the connection healthy via heartbeats; consumers see your bot as **online** while it's connected.
44
-
45
- ## API
46
-
47
- ### `new Bot(opts)`
48
-
49
- | Option | Type | Default | Notes |
50
- | -------------------- | ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
51
- | `id` | `string` (required) | — | Your bot's handle. Lowercase letters / digits / dashes. |
52
- | `apiKey` | `string` (required) | `process.env.BOT_SECRET` | Per-bot **Bot Secret** from the dashboard. The gateway validates it and scopes you to your own bot only. The constructor option name stays `apiKey` for backward compatibility with older SDK versions; the dashboard label and env var moved to "Bot Secret" / `BOT_SECRET` to disambiguate from user-level API keys (`lhduser_…`) used against the public REST API. |
53
- | `serverUrl` | `string` | `wss://bot-api.localhostdevs.com/bot` (override with `LHD_SERVER_URL`) | Production gateway is baked in — most users leave this alone. Only override for local dev or self-hosted gateways. |
54
- | `idempotencyLruSize` | `number` | `1024` | How many recent message IDs to remember for deduplication. |
55
-
56
- ### `bot.cmd(def, handler)`
57
-
58
- Register a handler for a command. Must be called before `connect()`. The first argument is a **command definition object** with the following fields:
59
-
60
- | Field | Type | Notes |
61
- | ------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
62
- | `name` | `string` (required) | Command name consumers dispatch (e.g. `'ping'`, `'topic'`). |
63
- | `description` | `string` | Human-readable description shown on the marketplace/workflow picker. |
64
- | `args` | `Record<string, ArgSpec>` | Declare the expected arguments (see below). **Advertise-only** by default — fills the marketplace and workflow picker but does NOT validate at runtime unless `validate: true`. |
65
- | `returns` | `{ kind: ReplyKind, data?: Record<string, 'string' \| 'number' \| 'boolean'> }` | Declare the reply kind. Enforced: the handler must call the matching reply method or the SDK throws (see Reply-kind enforcement below). |
66
- | `validate` | `boolean` | When `true`, the SDK validates `ctx.args` against the declared `args` before running your handler. A bad dispatch is rejected with a structured `invalid_args` error. Also narrows TypeScript types: `required` fields become typed-present in `ctx.args`. Without `validate`, all arg fields are typed optional. |
67
- | `streaming` | `boolean` | Override the bot-level streaming flag for this command only. |
68
- | `mimeType` | `string` | MIME-type hint advertised in IDENTIFY (advertising-only, doesn't affect runtime). |
69
-
70
- #### `ArgSpec` shape
71
-
72
- ```ts
73
- interface ArgSpec {
74
- type: 'string' | 'number' | 'boolean' | 'enum';
75
- required?: boolean;
76
- description?: string;
77
- values?: readonly string[]; // required when type === 'enum'
78
- }
79
- ```
80
-
81
- #### `returns.kind` vocabulary
82
-
83
- `'text'` | `'list'` | `'table'` | `'image'` | `'buttons'` | `'collect'`
84
-
85
- The declared kind is **enforced at runtime**: if the handler calls a reply method that doesn't match the declaration (e.g. `returns: { kind: 'list' }` but the handler calls `ctx.reply()`), the SDK throws a hard error. TypeScript also narrows `ctx`'s available reply methods to just the ones valid for the declared kind.
86
-
87
- #### Example
88
-
89
- ```ts
90
- bot.cmd({
91
- name: 'topic',
92
- description: 'Scrape trends for a query, then pick one',
93
- args: {
94
- query: { type: 'string', required: true, description: 'Search query' },
95
- format: { type: 'string', description: 'e.g. short_30s' },
96
- voice: { type: 'enum', values: ['on', 'off'] },
97
- },
98
- returns: { kind: 'list', data: { jobId: 'string', state: 'string' } },
99
- validate: false, // opt-in: when true, args are validated AND required fields typed-present
100
- }, async (ctx) => {
101
- // ctx.args is typed from `args`; ctx reply methods are narrowed to returns.kind
102
- await ctx.replyList([{ title: ctx.args.query ?? '' }]);
103
- });
104
- ```
105
-
106
- Handler receives a `CommandContext`:
107
-
108
- ```ts
109
- bot.cmd({ name: 'greet' }, async (ctx) => {
110
- // ctx.args: parsed args from the consumer
111
- // ctx.command: 'greet'
112
- // ctx.msgId: unique id for this invocation
113
-
114
- await ctx.reply({ text: `Hello, ${ctx.args.name ?? 'world'}!` });
115
-
116
- // ctx.reply also supports:
117
- // data: any — structured payload alongside text
118
- // dispatch: { to, command, args } — chain to another bot (max depth 5)
119
- });
120
- ```
121
-
122
- ### `await bot.connect()`
123
-
124
- Open the WebSocket to the gateway, authenticate, and start receiving commands. Resolves after the gateway sends `HELLO` (the SDK is ready for traffic).
125
-
126
- ### `await bot.disconnect()`
127
-
128
- Close the WebSocket cleanly.
129
-
130
- ## Idempotency
131
-
132
- Every command from the gateway carries a `msgId`. The SDK deduplicates redelivered messages via a fixed-size LRU keyed by `msgId`. If the gateway happens to deliver the same command twice, your handler runs once.
133
-
134
- ## Online / offline status
135
-
136
- The gateway pings the bot every 30 seconds and the SDK responds. If the gateway stops hearing from you for >60 seconds it marks the bot offline. Reconnect simply by starting the process again.
137
-
138
- ## Bot-to-bot composition
139
-
140
- A reply can carry a `dispatch` directive to chain a command to another bot:
141
-
142
- ```js
143
- bot.cmd({ name: 'summary' }, async (ctx) => {
144
- await ctx.reply({
145
- text: 'fetching price first…',
146
- dispatch: { to: 'price-bot', command: 'price', args: { ticker: 'AAPL' } },
147
- });
148
- });
149
- ```
150
-
151
- Chains run up to depth 5 with loop detection. The original consumer pays for every step.
152
-
153
- ## Structured replies (0.5.0+)
154
-
155
- By default `ctx.reply({ text })` sends a plain-text body — the consumer
156
- renders it as text. To get richer rendering (source-badge lists, striped
157
- tables) on the dashboard Test Console and the public Try widget, return a
158
- **kinded** body via the helpers:
159
-
160
- ```js
161
- // List output
162
- bot.cmd({
163
- name: 'headlines',
164
- returns: { kind: 'list' },
165
- }, async (ctx) => {
166
- await ctx.replyList(
167
- [
168
- { label: 'AP', title: 'Story one', secondary: '2m', href: '…' },
169
- { label: 'Reuters', title: 'Story two', secondary: '5m' },
170
- ],
171
- { header: '2 headlines · global' }, // optional
172
- );
173
- });
174
-
175
- // Table output
176
- bot.cmd({
177
- name: 'top5',
178
- returns: { kind: 'table' },
179
- }, async (ctx) => {
180
- await ctx.replyTable({
181
- headers: ['ticker', 'price', 'change'],
182
- rows: [
183
- ['AAPL', 188.12, 1.4],
184
- ['MSFT', 412.50, null],
185
- ],
186
- });
187
- });
188
- ```
189
-
190
- **Body shapes on the wire** (the gateway also wraps legacy `{ text }`
191
- replies in `{ kind: 'text', text }` so consumers always see a
192
- discriminated union):
193
-
194
- ```ts
195
- type BotResponse =
196
- | { kind: 'text'; text: string; mimeType?: string; data?: unknown }
197
- | { kind: 'list'; header?: string; items: ListItem[] }
198
- | { kind: 'table'; headers: string[]; rows: Array<Array<string | number | null>> };
199
-
200
- interface ListItem {
201
- label?: string; // small badge on the left, e.g. "AP"
202
- title: string; // primary line
203
- secondary?: string; // trailing text, e.g. "2m"
204
- href?: string; // optional click-through
205
- }
206
- ```
207
-
208
- **Validation.** `replyList()` rejects empty `items` and items missing a
209
- `title`. `replyTable()` rejects empty `headers` and rows whose cell count
210
- doesn't match `headers.length`. Validation runs BEFORE the once-only reply
211
- guard flips, so if you catch the error you can still send a corrected
212
- `ctx.reply()`. After a successful reply (via any of the three methods),
213
- further reply calls throw — the SDK guarantees exactly-one reply per
214
- command.
215
-
216
- **Reply-kind enforcement.** When `returns.kind` is declared on the command
217
- definition, the SDK enforces it at runtime: calling a mismatched reply
218
- method (e.g. `ctx.reply()` when `returns: { kind: 'list' }`) throws a hard
219
- error. TypeScript also narrows `ctx`'s available reply methods to the ones
220
- valid for the declared kind, so mismatches are caught at compile time.
221
-
222
- ## Streaming progress (`ctx.update`)
223
-
224
- Long-running commands can emit intermediate progress frames before the
225
- final reply. Enable per-bot or per-command:
226
-
227
- ```js
228
- const bot = new Bot({
229
- id: 'analyze-bot',
230
- streaming: true, // enables ctx.update across all commands
231
- });
232
-
233
- bot.cmd({ name: 'analyze' }, async (ctx) => {
234
- await ctx.update({ progress: 10, text: 'Fetching…' });
235
- await ctx.update({ progress: 60, text: 'Crunching…' });
236
- await ctx.reply({
237
- text: 'done',
238
- mimeType: 'image/png',
239
- data: chartBytesBase64,
240
- });
241
- });
242
- ```
243
-
244
- Or per-command using `streaming` in the definition:
245
-
246
- ```js
247
- bot.cmd({ name: 'analyze', streaming: true }, async (ctx) => { /* */ });
248
- bot.cmd({ name: 'ping' }, async (ctx) => { /* no ctx.update here */ });
249
- ```
250
-
251
- **Progress shapes** (use whichever fits the work):
252
-
253
- - `number` — percent (0–100). Renders as a horizontal bar.
254
- - `{ current, total }` — step counter. Renders as "5 / 30".
255
- - `{ label: string }` — indeterminate. Renders as a spinner + label.
256
-
257
- **MIME-type hints** (`mimeType` on both update and reply bodies) let the
258
- consumer choose how to render the body. v1 chat UI handles
259
- `text/plain` (default) and `text/markdown`; others fall back to a code
260
- block of the data field.
261
-
262
- **Consumer contract**: when the public REST API caller hits
263
- `POST /api/v1/bots/{handle}/dispatch` with
264
- `Accept: text/event-stream` or `Accept: application/x-ndjson`, they
265
- receive each frame as it arrives. Default `Accept: application/json`
266
- (or no Accept) returns only the final frame as a single JSON — backward
267
- compatible with everything written before this release. See
268
- `/docs/api` on localhostdevs.com for full details.
269
-
270
- ### Declaring mimeType per command (0.4.1+)
271
-
272
- Tell the platform what each command's reply looks like so the bot
273
- detail page can show consumers what to expect:
274
-
275
- ```js
276
- bot.cmd({ name: 'chart', streaming: true, mimeType: 'image/png' }, async (ctx) => {
277
- await ctx.reply({ data: chartBytes, mimeType: 'image/png' });
278
- });
279
-
280
- bot.cmd({ name: 'summarize', mimeType: 'text/markdown' }, async (ctx) => {
281
- await ctx.reply({ text: '# Summary\n…', mimeType: 'text/markdown' });
282
- });
283
-
284
- bot.cmd({ name: 'ping' }, async (ctx) => { // no mimeType displays as text/plain
285
- await ctx.reply({ text: 'pong' });
286
- });
287
- ```
288
-
289
- The declaration is **advertising-only** the actual mimeType on the
290
- wire is whatever `ctx.reply({ mimeType })` passes. Mismatches don't
291
- crash anything; the bot detail page shows the declared value and the
292
- consumer renders whatever the runtime reply specifies.
293
-
294
- ## v0.3.5: Bot Secret env-var rename
295
-
296
- The env var name moved from `LHD_API_KEY` to `BOT_SECRET` to disambiguate from
297
- user-level public-API keys (`lhduser_…`). Rename the env var in your bot's
298
- deployment — no code change needed if you used the env-var path:
299
-
300
- ```diff
301
- - LHD_API_KEY=lhd_live_… node app.js
302
- + BOT_SECRET=lhd_live_… node app.js
303
- ```
304
-
305
- If you pass `apiKey` explicitly into the constructor, no change at all —
306
- the option name on `BotOptions` is preserved.
307
-
308
- ## Migrating from v0.2 v0.3 (gateway pivot)
309
-
310
- v0.3 is a breaking change vs v0.2 — the SDK no longer talks to NATS directly. It opens a WebSocket to the managed gateway and auths with a per-bot Bot Secret:
311
-
312
- | Was (v0.2) | Now (v0.3+) |
313
- | -------------------------------------- | ------------------------------ |
314
- | `new Bot({ id, natsUrl: 'nats://…' })` | `new Bot({ id, apiKey: '…' })` |
315
- | `LHD_NATS_TOKEN=… node app.js` | `BOT_SECRET=… node app.js` |
316
-
317
- Drop the `natsUrl` and `natsToken` options entirely — the SDK figures out the right gateway URL on its own. Use your bot's **Bot Secret** from the dashboard (not the old shared broker token).
318
-
319
- ## v0.3.x v0.4 (streaming opt-in)
320
-
321
- v0.4 is additive nothing breaks for bots calling only `ctx.reply()`.
322
- New surface:
323
-
324
- - `new Bot({ …, streaming: true })` to enable `ctx.update()`
325
- - `streaming: true` inside the command definition to flip the flag per command
326
- - `ctx.update({ progress?, text?, data?, mimeType? })` — send 1+
327
- intermediate frames before `ctx.reply()`
328
- - `mimeType` on `ctx.reply()` for the existing final-reply path
329
-
330
- No env-var changes, no protocol breakage, no upstream re-auth.
331
-
332
- ## v0.4.0 v0.4.1 (per-command mimeType)
333
-
334
- Additive — no breaking changes. The `mimeType?` field in the command definition
335
- is optional; bots that ignore it work exactly the same. The SDK auto-builds an
336
- IDENTIFY-time `commands` array from your registered handlers so the platform can
337
- display them on the bot detail page.
338
-
339
- ## v0.14 v0.15 (object `cmd()`)
340
-
341
- **Breaking.** See [MIGRATION.md](./MIGRATION.md) for the full guide.
342
-
343
- `cmd()` now takes a **command definition object** as its first argument. The
344
- string-form is removed.
345
-
346
- ```diff
347
- - bot.cmd("ping", async (ctx) => { … })
348
- + bot.cmd({ name: "ping" }, async (ctx) => { })
349
-
350
- - bot.cmd("analyze", { streaming: true }, async (ctx) => { … })
351
- + bot.cmd({ name: "analyze", streaming: true }, async (ctx) => { … })
352
- ```
353
-
354
- ## License
355
-
356
- MIT
1
+ # @localhostdevs/sdk
2
+
3
+ The JavaScript SDK for building bots on **[localhostdevs](https://localhostdevs.com)** — run your app on your own laptop and publish it as a command-driven bot that anyone can use through a chat-style interface.
4
+
5
+ > Pre-1.0: API surface may break in minor versions (`0.x → 0.y`). Once the platform launches with external users, we'll cut a 1.0 that promises stability.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install @localhostdevs/sdk
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ After creating a bot in the [dashboard](https://localhostdevs.com/dashboard/bots) and copying your API key, write this to `app.js`:
16
+
17
+ ```js
18
+ import { Bot } from '@localhostdevs/sdk';
19
+
20
+ const bot = new Bot({
21
+ id: 'price-bot', // your bot's handle (matches the @handle on the marketplace)
22
+ // apiKey: process.env.BOT_SECRET, // optional; auto-read from env if unset
23
+ });
24
+
25
+ bot.cmd({ name: 'ping' }, async (ctx) => {
26
+ await ctx.reply({ text: 'pong' });
27
+ });
28
+
29
+ bot.cmd({ name: 'echo' }, async (ctx) => {
30
+ await ctx.reply({ text: `echo: ${JSON.stringify(ctx.args)}` });
31
+ });
32
+
33
+ await bot.connect();
34
+ console.log('bot online — waiting for commands');
35
+ ```
36
+
37
+ Then:
38
+
39
+ ```bash
40
+ BOT_SECRET=<your Bot Secret from the dashboard> node app.js
41
+ ```
42
+
43
+ The bot stays online for as long as the process runs. The gateway keeps the connection healthy via heartbeats; consumers see your bot as **online** while it's connected.
44
+
45
+ ## API
46
+
47
+ ### `new Bot(opts)`
48
+
49
+ | Option | Type | Default | Notes |
50
+ | -------------------- | ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
51
+ | `id` | `string` (required) | — | Your bot's handle. Lowercase letters / digits / dashes. |
52
+ | `apiKey` | `string` (required) | `process.env.BOT_SECRET` | Per-bot **Bot Secret** from the dashboard. The gateway validates it and scopes you to your own bot only. The constructor option name stays `apiKey` for backward compatibility with older SDK versions; the dashboard label and env var moved to "Bot Secret" / `BOT_SECRET` to disambiguate from user-level API keys (`lhduser_…`) used against the public REST API. |
53
+ | `serverUrl` | `string` | `wss://bot-api.localhostdevs.com/bot` (override with `LHD_SERVER_URL`) | Production gateway is baked in — most users leave this alone. Only override for local dev or self-hosted gateways. |
54
+ | `idempotencyLruSize` | `number` | `1024` | How many recent message IDs to remember for deduplication. |
55
+
56
+ ### `bot.cmd(def, handler)`
57
+
58
+ Register a handler for a command. Must be called before `connect()`. The first argument is a **command definition object** with the following fields:
59
+
60
+ | Field | Type | Notes |
61
+ | ------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
62
+ | `name` | `string` (required) | Command name consumers dispatch (e.g. `'ping'`, `'topic'`). |
63
+ | `description` | `string` | Human-readable description shown on the marketplace/workflow picker. |
64
+ | `args` | `Record<string, ArgSpec>` | Declare the expected arguments (see below). **Advertise-only** by default — fills the marketplace and workflow picker but does NOT validate at runtime unless `validate: true`. |
65
+ | `returns` | `{ kind: ReplyKind, data?: Record<string, 'string' \| 'number' \| 'boolean'> }` | Declare the reply kind. Enforced: the handler must call the matching reply method or the SDK throws (see Reply-kind enforcement below). |
66
+ | `validate` | `boolean` | When `true`, the SDK validates `ctx.args` against the declared `args` before running your handler. A bad dispatch is rejected with a structured `invalid_args` error. Also narrows TypeScript types: `required` fields become typed-present in `ctx.args`. Without `validate`, all arg fields are typed optional. |
67
+ | `streaming` | `boolean` | Override the bot-level streaming flag for this command only. |
68
+ | `mimeType` | `string` | MIME-type hint advertised in IDENTIFY (advertising-only, doesn't affect runtime). |
69
+
70
+ #### `ArgSpec` shape
71
+
72
+ ```ts
73
+ interface ArgSpec {
74
+ type: 'string' | 'number' | 'boolean' | 'enum';
75
+ required?: boolean;
76
+ description?: string;
77
+ values?: readonly string[]; // required when type === 'enum'
78
+ }
79
+ ```
80
+
81
+ #### `returns.kind` vocabulary
82
+
83
+ `'text'` | `'list'` | `'table'` | `'image'` | `'buttons'` | `'collect'`
84
+
85
+ The declared kind is **enforced at runtime**: if the handler calls a reply method that doesn't match the declaration (e.g. `returns: { kind: 'list' }` but the handler calls `ctx.reply()`), the SDK throws a hard error. TypeScript also narrows `ctx`'s available reply methods to just the ones valid for the declared kind.
86
+
87
+ #### Example
88
+
89
+ ```ts
90
+ bot.cmd({
91
+ name: 'topic',
92
+ description: 'Scrape trends for a query, then pick one',
93
+ args: {
94
+ query: { type: 'string', required: true, description: 'Search query' },
95
+ format: { type: 'string', description: 'e.g. short_30s' },
96
+ voice: { type: 'enum', values: ['on', 'off'] },
97
+ },
98
+ returns: { kind: 'list', data: { jobId: 'string', state: 'string' } },
99
+ validate: false, // opt-in: when true, args are validated AND required fields typed-present
100
+ }, async (ctx) => {
101
+ // ctx.args is typed from `args`; ctx reply methods are narrowed to returns.kind
102
+ await ctx.replyList([{ title: ctx.args.query ?? '' }]);
103
+ });
104
+ ```
105
+
106
+ Handler receives a `CommandContext`:
107
+
108
+ ```ts
109
+ bot.cmd({ name: 'greet' }, async (ctx) => {
110
+ // ctx.args: parsed args from the consumer
111
+ // ctx.command: 'greet'
112
+ // ctx.msgId: unique id for this invocation
113
+
114
+ await ctx.reply({ text: `Hello, ${ctx.args.name ?? 'world'}!` });
115
+
116
+ // ctx.reply also supports:
117
+ // data: any — structured payload alongside text
118
+ // dispatch: { to, command, args } — chain to another bot (max depth 5)
119
+ });
120
+ ```
121
+
122
+ ### `bot.register(...descriptors)`
123
+
124
+ Register one or more commands from **descriptor objects** the composition-root
125
+ API. A descriptor is `{ command, handler }`, where `command` is the same object
126
+ `cmd()` takes and `handler` is the same handler. This lets a command live in its
127
+ own file and `app.js` just wire it up:
128
+
129
+ ```js
130
+ // commands/ping.js
131
+ export default {
132
+ command: { name: 'ping', description: 'Replies with pong', returns: { kind: 'text' } },
133
+ handler: async (ctx) => { await ctx.reply({ text: 'pong' }) },
134
+ }
135
+
136
+ // app.js the composition root
137
+ import ping from './commands/ping.js'
138
+ import echo from './commands/echo.js'
139
+
140
+ const bot = new Bot({ id: 'my-bot' })
141
+ bot.register(ping).register(echo) // chainable; or bot.register(ping, echo)
142
+ await bot.connect()
143
+ ```
144
+
145
+ `register` is chainable (returns the bot) and variadic. It forwards to `cmd()`,
146
+ so all the same rules apply (must be called before `connect()`; duplicate names
147
+ throw). `cmd()` remains fully supported for inline commands.
148
+
149
+ ### `await bot.connect()`
150
+
151
+ Open the WebSocket to the gateway, authenticate, and start receiving commands. Resolves after the gateway sends `HELLO` (the SDK is ready for traffic).
152
+
153
+ ### `await bot.disconnect()`
154
+
155
+ Close the WebSocket cleanly.
156
+
157
+ ## Idempotency
158
+
159
+ Every command from the gateway carries a `msgId`. The SDK deduplicates redelivered messages via a fixed-size LRU keyed by `msgId`. If the gateway happens to deliver the same command twice, your handler runs once.
160
+
161
+ ## Online / offline status
162
+
163
+ The gateway pings the bot every 30 seconds and the SDK responds. If the gateway stops hearing from you for >60 seconds it marks the bot offline. Reconnect simply by starting the process again.
164
+
165
+ ## Bot-to-bot composition
166
+
167
+ A reply can carry a `dispatch` directive to chain a command to another bot:
168
+
169
+ ```js
170
+ bot.cmd({ name: 'summary' }, async (ctx) => {
171
+ await ctx.reply({
172
+ text: 'fetching price first…',
173
+ dispatch: { to: 'price-bot', command: 'price', args: { ticker: 'AAPL' } },
174
+ });
175
+ });
176
+ ```
177
+
178
+ Chains run up to depth 5 with loop detection. The original consumer pays for every step.
179
+
180
+ ## Structured replies (0.5.0+)
181
+
182
+ By default `ctx.reply({ text })` sends a plain-text body — the consumer
183
+ renders it as text. To get richer rendering (source-badge lists, striped
184
+ tables) on the dashboard Test Console and the public Try widget, return a
185
+ **kinded** body via the helpers:
186
+
187
+ ```js
188
+ // List output
189
+ bot.cmd({
190
+ name: 'headlines',
191
+ returns: { kind: 'list' },
192
+ }, async (ctx) => {
193
+ await ctx.replyList(
194
+ [
195
+ { label: 'AP', title: 'Story one', secondary: '2m', href: '…' },
196
+ { label: 'Reuters', title: 'Story two', secondary: '5m' },
197
+ ],
198
+ { header: '2 headlines · global' }, // optional
199
+ );
200
+ });
201
+
202
+ // Table output
203
+ bot.cmd({
204
+ name: 'top5',
205
+ returns: { kind: 'table' },
206
+ }, async (ctx) => {
207
+ await ctx.replyTable({
208
+ headers: ['ticker', 'price', 'change'],
209
+ rows: [
210
+ ['AAPL', 188.12, 1.4],
211
+ ['MSFT', 412.50, null],
212
+ ],
213
+ });
214
+ });
215
+ ```
216
+
217
+ **Body shapes on the wire** (the gateway also wraps legacy `{ text }`
218
+ replies in `{ kind: 'text', text }` so consumers always see a
219
+ discriminated union):
220
+
221
+ ```ts
222
+ type BotResponse =
223
+ | { kind: 'text'; text: string; mimeType?: string; data?: unknown }
224
+ | { kind: 'list'; header?: string; items: ListItem[] }
225
+ | { kind: 'table'; headers: string[]; rows: Array<Array<string | number | null>> };
226
+
227
+ interface ListItem {
228
+ label?: string; // small badge on the left, e.g. "AP"
229
+ title: string; // primary line
230
+ secondary?: string; // trailing text, e.g. "2m"
231
+ href?: string; // optional click-through
232
+ }
233
+ ```
234
+
235
+ **Validation.** `replyList()` rejects empty `items` and items missing a
236
+ `title`. `replyTable()` rejects empty `headers` and rows whose cell count
237
+ doesn't match `headers.length`. Validation runs BEFORE the once-only reply
238
+ guard flips, so if you catch the error you can still send a corrected
239
+ `ctx.reply()`. After a successful reply (via any of the three methods),
240
+ further reply calls throw — the SDK guarantees exactly-one reply per
241
+ command.
242
+
243
+ **Reply-kind enforcement.** When `returns.kind` is declared on the command
244
+ definition, the SDK enforces it at runtime: calling a mismatched reply
245
+ method (e.g. `ctx.reply()` when `returns: { kind: 'list' }`) throws a hard
246
+ error. TypeScript also narrows `ctx`'s available reply methods to the ones
247
+ valid for the declared kind, so mismatches are caught at compile time.
248
+
249
+ ## Streaming progress (`ctx.update`)
250
+
251
+ Long-running commands can emit intermediate progress frames before the
252
+ final reply. Enable per-bot or per-command:
253
+
254
+ ```js
255
+ const bot = new Bot({
256
+ id: 'analyze-bot',
257
+ streaming: true, // enables ctx.update across all commands
258
+ });
259
+
260
+ bot.cmd({ name: 'analyze' }, async (ctx) => {
261
+ await ctx.update({ progress: 10, text: 'Fetching…' });
262
+ await ctx.update({ progress: 60, text: 'Crunching…' });
263
+ await ctx.reply({
264
+ text: 'done',
265
+ mimeType: 'image/png',
266
+ data: chartBytesBase64,
267
+ });
268
+ });
269
+ ```
270
+
271
+ Or per-command using `streaming` in the definition:
272
+
273
+ ```js
274
+ bot.cmd({ name: 'analyze', streaming: true }, async (ctx) => { /* … */ });
275
+ bot.cmd({ name: 'ping' }, async (ctx) => { /* no ctx.update here */ });
276
+ ```
277
+
278
+ **Progress shapes** (use whichever fits the work):
279
+
280
+ - `number` percent (0–100). Renders as a horizontal bar.
281
+ - `{ current, total }` step counter. Renders as "5 / 30".
282
+ - `{ label: string }` — indeterminate. Renders as a spinner + label.
283
+
284
+ **MIME-type hints** (`mimeType` on both update and reply bodies) let the
285
+ consumer choose how to render the body. v1 chat UI handles
286
+ `text/plain` (default) and `text/markdown`; others fall back to a code
287
+ block of the data field.
288
+
289
+ **Consumer contract**: when the public REST API caller hits
290
+ `POST /api/v1/bots/{handle}/dispatch` with
291
+ `Accept: text/event-stream` or `Accept: application/x-ndjson`, they
292
+ receive each frame as it arrives. Default `Accept: application/json`
293
+ (or no Accept) returns only the final frame as a single JSON — backward
294
+ compatible with everything written before this release. See
295
+ `/docs/api` on localhostdevs.com for full details.
296
+
297
+ ### Declaring mimeType per command (0.4.1+)
298
+
299
+ Tell the platform what each command's reply looks like so the bot
300
+ detail page can show consumers what to expect:
301
+
302
+ ```js
303
+ bot.cmd({ name: 'chart', streaming: true, mimeType: 'image/png' }, async (ctx) => {
304
+ await ctx.reply({ data: chartBytes, mimeType: 'image/png' });
305
+ });
306
+
307
+ bot.cmd({ name: 'summarize', mimeType: 'text/markdown' }, async (ctx) => {
308
+ await ctx.reply({ text: '# Summary\n…', mimeType: 'text/markdown' });
309
+ });
310
+
311
+ bot.cmd({ name: 'ping' }, async (ctx) => { // no mimeType — displays as text/plain
312
+ await ctx.reply({ text: 'pong' });
313
+ });
314
+ ```
315
+
316
+ The declaration is **advertising-only** — the actual mimeType on the
317
+ wire is whatever `ctx.reply({ mimeType })` passes. Mismatches don't
318
+ crash anything; the bot detail page shows the declared value and the
319
+ consumer renders whatever the runtime reply specifies.
320
+
321
+ ## v0.3.5: Bot Secret env-var rename
322
+
323
+ The env var name moved from `LHD_API_KEY` to `BOT_SECRET` to disambiguate from
324
+ user-level public-API keys (`lhduser_…`). Rename the env var in your bot's
325
+ deployment no code change needed if you used the env-var path:
326
+
327
+ ```diff
328
+ - LHD_API_KEY=lhd_live_… node app.js
329
+ + BOT_SECRET=lhd_live_… node app.js
330
+ ```
331
+
332
+ If you pass `apiKey` explicitly into the constructor, no change at all —
333
+ the option name on `BotOptions` is preserved.
334
+
335
+ ## Migrating from v0.2 v0.3 (gateway pivot)
336
+
337
+ v0.3 is a breaking change vs v0.2 — the SDK no longer talks to NATS directly. It opens a WebSocket to the managed gateway and auths with a per-bot Bot Secret:
338
+
339
+ | Was (v0.2) | Now (v0.3+) |
340
+ | -------------------------------------- | ------------------------------ |
341
+ | `new Bot({ id, natsUrl: 'nats://…' })` | `new Bot({ id, apiKey: '…' })` |
342
+ | `LHD_NATS_TOKEN=… node app.js` | `BOT_SECRET=… node app.js` |
343
+
344
+ Drop the `natsUrl` and `natsToken` options entirely — the SDK figures out the right gateway URL on its own. Use your bot's **Bot Secret** from the dashboard (not the old shared broker token).
345
+
346
+ ## v0.3.x → v0.4 (streaming opt-in)
347
+
348
+ v0.4 is additive nothing breaks for bots calling only `ctx.reply()`.
349
+ New surface:
350
+
351
+ - `new Bot({ …, streaming: true })` to enable `ctx.update()`
352
+ - `streaming: true` inside the command definition to flip the flag per command
353
+ - `ctx.update({ progress?, text?, data?, mimeType? })` — send 1+
354
+ intermediate frames before `ctx.reply()`
355
+ - `mimeType` on `ctx.reply()` for the existing final-reply path
356
+
357
+ No env-var changes, no protocol breakage, no upstream re-auth.
358
+
359
+ ## v0.4.0 → v0.4.1 (per-command mimeType)
360
+
361
+ Additive — no breaking changes. The `mimeType?` field in the command definition
362
+ is optional; bots that ignore it work exactly the same. The SDK auto-builds an
363
+ IDENTIFY-time `commands` array from your registered handlers so the platform can
364
+ display them on the bot detail page.
365
+
366
+ ## v0.14 → v0.15 (object `cmd()`)
367
+
368
+ **Breaking.** See [MIGRATION.md](./MIGRATION.md) for the full guide.
369
+
370
+ `cmd()` now takes a **command definition object** as its first argument. The
371
+ string-form is removed.
372
+
373
+ ```diff
374
+ - bot.cmd("ping", async (ctx) => { … })
375
+ + bot.cmd({ name: "ping" }, async (ctx) => { … })
376
+
377
+ - bot.cmd("analyze", { streaming: true }, async (ctx) => { … })
378
+ + bot.cmd({ name: "analyze", streaming: true }, async (ctx) => { … })
379
+ ```
380
+
381
+ ## License
382
+
383
+ MIT