@localhostdevs/sdk
v0.16.0
Published
JavaScript SDK for building bots on localhostdevs — run your app on your laptop, publish it as a command-driven bot.
Maintainers
Readme
@localhostdevs/sdk
The JavaScript SDK for building bots on localhostdevs — run your app on your own laptop and publish it as a command-driven bot that anyone can use through a chat-style interface.
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.
Install
npm install @localhostdevs/sdkQuickstart
After creating a bot in the dashboard and copying your API key, write this to app.js:
import { Bot } from '@localhostdevs/sdk';
const bot = new Bot({
id: 'price-bot', // your bot's handle (matches the @handle on the marketplace)
// apiKey: process.env.BOT_SECRET, // optional; auto-read from env if unset
});
bot.cmd({ name: 'ping' }, async (ctx) => {
await ctx.reply({ text: 'pong' });
});
bot.cmd({ name: 'echo' }, async (ctx) => {
await ctx.reply({ text: `echo: ${JSON.stringify(ctx.args)}` });
});
await bot.connect();
console.log('bot online — waiting for commands');Then:
BOT_SECRET=<your Bot Secret from the dashboard> node app.jsThe 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.
API
new Bot(opts)
| Option | Type | Default | Notes |
| -------------------- | ------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| id | string (required) | — | Your bot's handle. Lowercase letters / digits / dashes. |
| 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. |
| 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. |
| idempotencyLruSize | number | 1024 | How many recent message IDs to remember for deduplication. |
bot.cmd(def, handler)
Register a handler for a command. Must be called before connect(). The first argument is a command definition object with the following fields:
| Field | Type | Notes |
| ------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| name | string (required) | Command name consumers dispatch (e.g. 'ping', 'topic'). |
| description | string | Human-readable description shown on the marketplace/workflow picker. |
| 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. |
| 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). |
| 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. |
| streaming | boolean | Override the bot-level streaming flag for this command only. |
| mimeType | string | MIME-type hint advertised in IDENTIFY (advertising-only, doesn't affect runtime). |
ArgSpec shape
interface ArgSpec {
type: 'string' | 'number' | 'boolean' | 'enum';
required?: boolean;
description?: string;
values?: readonly string[]; // required when type === 'enum'
}returns.kind vocabulary
'text' | 'list' | 'table' | 'image' | 'buttons' | 'collect'
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.
Example
bot.cmd({
name: 'topic',
description: 'Scrape trends for a query, then pick one',
args: {
query: { type: 'string', required: true, description: 'Search query' },
format: { type: 'string', description: 'e.g. short_30s' },
voice: { type: 'enum', values: ['on', 'off'] },
},
returns: { kind: 'list', data: { jobId: 'string', state: 'string' } },
validate: false, // opt-in: when true, args are validated AND required fields typed-present
}, async (ctx) => {
// ctx.args is typed from `args`; ctx reply methods are narrowed to returns.kind
await ctx.replyList([{ title: ctx.args.query ?? '' }]);
});Handler receives a CommandContext:
bot.cmd({ name: 'greet' }, async (ctx) => {
// ctx.args: parsed args from the consumer
// ctx.command: 'greet'
// ctx.msgId: unique id for this invocation
await ctx.reply({ text: `Hello, ${ctx.args.name ?? 'world'}!` });
// ctx.reply also supports:
// data: any — structured payload alongside text
// dispatch: { to, command, args } — chain to another bot (max depth 5)
});await bot.connect()
Open the WebSocket to the gateway, authenticate, and start receiving commands. Resolves after the gateway sends HELLO (the SDK is ready for traffic).
await bot.disconnect()
Close the WebSocket cleanly.
Idempotency
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.
Online / offline status
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.
Bot-to-bot composition
A reply can carry a dispatch directive to chain a command to another bot:
bot.cmd({ name: 'summary' }, async (ctx) => {
await ctx.reply({
text: 'fetching price first…',
dispatch: { to: 'price-bot', command: 'price', args: { ticker: 'AAPL' } },
});
});Chains run up to depth 5 with loop detection. The original consumer pays for every step.
Structured replies (0.5.0+)
By default ctx.reply({ text }) sends a plain-text body — the consumer
renders it as text. To get richer rendering (source-badge lists, striped
tables) on the dashboard Test Console and the public Try widget, return a
kinded body via the helpers:
// List output
bot.cmd({
name: 'headlines',
returns: { kind: 'list' },
}, async (ctx) => {
await ctx.replyList(
[
{ label: 'AP', title: 'Story one', secondary: '2m', href: '…' },
{ label: 'Reuters', title: 'Story two', secondary: '5m' },
],
{ header: '2 headlines · global' }, // optional
);
});
// Table output
bot.cmd({
name: 'top5',
returns: { kind: 'table' },
}, async (ctx) => {
await ctx.replyTable({
headers: ['ticker', 'price', 'change'],
rows: [
['AAPL', 188.12, 1.4],
['MSFT', 412.50, null],
],
});
});Body shapes on the wire (the gateway also wraps legacy { text }
replies in { kind: 'text', text } so consumers always see a
discriminated union):
type BotResponse =
| { kind: 'text'; text: string; mimeType?: string; data?: unknown }
| { kind: 'list'; header?: string; items: ListItem[] }
| { kind: 'table'; headers: string[]; rows: Array<Array<string | number | null>> };
interface ListItem {
label?: string; // small badge on the left, e.g. "AP"
title: string; // primary line
secondary?: string; // trailing text, e.g. "2m"
href?: string; // optional click-through
}Validation. replyList() rejects empty items and items missing a
title. replyTable() rejects empty headers and rows whose cell count
doesn't match headers.length. Validation runs BEFORE the once-only reply
guard flips, so if you catch the error you can still send a corrected
ctx.reply(). After a successful reply (via any of the three methods),
further reply calls throw — the SDK guarantees exactly-one reply per
command.
Reply-kind enforcement. When returns.kind is declared on the command
definition, the SDK enforces it at runtime: calling a mismatched reply
method (e.g. ctx.reply() when returns: { kind: 'list' }) throws a hard
error. TypeScript also narrows ctx's available reply methods to the ones
valid for the declared kind, so mismatches are caught at compile time.
Streaming progress (ctx.update)
Long-running commands can emit intermediate progress frames before the final reply. Enable per-bot or per-command:
const bot = new Bot({
id: 'analyze-bot',
streaming: true, // enables ctx.update across all commands
});
bot.cmd({ name: 'analyze' }, async (ctx) => {
await ctx.update({ progress: 10, text: 'Fetching…' });
await ctx.update({ progress: 60, text: 'Crunching…' });
await ctx.reply({
text: 'done',
mimeType: 'image/png',
data: chartBytesBase64,
});
});Or per-command using streaming in the definition:
bot.cmd({ name: 'analyze', streaming: true }, async (ctx) => { /* … */ });
bot.cmd({ name: 'ping' }, async (ctx) => { /* no ctx.update here */ });Progress shapes (use whichever fits the work):
number— percent (0–100). Renders as a horizontal bar.{ current, total }— step counter. Renders as "5 / 30".{ label: string }— indeterminate. Renders as a spinner + label.
MIME-type hints (mimeType on both update and reply bodies) let the
consumer choose how to render the body. v1 chat UI handles
text/plain (default) and text/markdown; others fall back to a code
block of the data field.
Consumer contract: when the public REST API caller hits
POST /api/v1/bots/{handle}/dispatch with
Accept: text/event-stream or Accept: application/x-ndjson, they
receive each frame as it arrives. Default Accept: application/json
(or no Accept) returns only the final frame as a single JSON — backward
compatible with everything written before this release. See
/docs/api on localhostdevs.com for full details.
Declaring mimeType per command (0.4.1+)
Tell the platform what each command's reply looks like so the bot detail page can show consumers what to expect:
bot.cmd({ name: 'chart', streaming: true, mimeType: 'image/png' }, async (ctx) => {
await ctx.reply({ data: chartBytes, mimeType: 'image/png' });
});
bot.cmd({ name: 'summarize', mimeType: 'text/markdown' }, async (ctx) => {
await ctx.reply({ text: '# Summary\n…', mimeType: 'text/markdown' });
});
bot.cmd({ name: 'ping' }, async (ctx) => { // no mimeType — displays as text/plain
await ctx.reply({ text: 'pong' });
});The declaration is advertising-only — the actual mimeType on the
wire is whatever ctx.reply({ mimeType }) passes. Mismatches don't
crash anything; the bot detail page shows the declared value and the
consumer renders whatever the runtime reply specifies.
v0.3.5: Bot Secret env-var rename
The env var name moved from LHD_API_KEY to BOT_SECRET to disambiguate from
user-level public-API keys (lhduser_…). Rename the env var in your bot's
deployment — no code change needed if you used the env-var path:
- LHD_API_KEY=lhd_live_… node app.js
+ BOT_SECRET=lhd_live_… node app.jsIf you pass apiKey explicitly into the constructor, no change at all —
the option name on BotOptions is preserved.
Migrating from v0.2 → v0.3 (gateway pivot)
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:
| Was (v0.2) | Now (v0.3+) |
| -------------------------------------- | ------------------------------ |
| new Bot({ id, natsUrl: 'nats://…' }) | new Bot({ id, apiKey: '…' }) |
| LHD_NATS_TOKEN=… node app.js | BOT_SECRET=… node app.js |
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).
v0.3.x → v0.4 (streaming opt-in)
v0.4 is additive — nothing breaks for bots calling only ctx.reply().
New surface:
new Bot({ …, streaming: true })to enablectx.update()streaming: trueinside the command definition to flip the flag per commandctx.update({ progress?, text?, data?, mimeType? })— send 1+ intermediate frames beforectx.reply()mimeTypeonctx.reply()for the existing final-reply path
No env-var changes, no protocol breakage, no upstream re-auth.
v0.4.0 → v0.4.1 (per-command mimeType)
Additive — no breaking changes. The mimeType? field in the command definition
is optional; bots that ignore it work exactly the same. The SDK auto-builds an
IDENTIFY-time commands array from your registered handlers so the platform can
display them on the bot detail page.
v0.14 → v0.15 (object cmd())
Breaking. See MIGRATION.md for the full guide.
cmd() now takes a command definition object as its first argument. The
string-form is removed.
- bot.cmd("ping", async (ctx) => { … })
+ bot.cmd({ name: "ping" }, async (ctx) => { … })
- bot.cmd("analyze", { streaming: true }, async (ctx) => { … })
+ bot.cmd({ name: "analyze", streaming: true }, async (ctx) => { … })License
MIT
