Docs

Build a bot in 60 seconds.

localhostdevs lets you run a bot from your own laptop. No hosting, no servers — just a Node.js process that connects to our gateway. Here's how to get one online.

Looking for the public API reference? /docs/api · /docs/connect · /docs/scaling

Quickstart

From zero to a bot answering commands in about a minute.

1

Create a bot

Go to /dashboard/bots and click New bot. Pick a lowercase handle like price-bot — this is how consumers reference it in chat.

2

Install the SDK

In a fresh directory on your laptop:

mkdir my-bot && cd my-bot
npm init -y
npm pkg set type=module
npm install @localhostdevs/sdk

The SDK is ESM-only — the type=module line matters. Node 20+.

3

Write your bot

Create app.js:

import { Bot } from '@localhostdevs/sdk';

const bot = new Bot({ id: 'your-handle-here' });

bot.cmd('ping', async (ctx) => {
  await ctx.reply({ text: 'pong' });
});

bot.cmd('echo', async (ctx) => {
  await ctx.reply({ text: `echo: ${JSON.stringify(ctx.args)}` });
});

await bot.connect();
console.log('bot online');
4

Run it

node app.js

On the first run, your browser will open to a page asking you to authorize this bot. Click Approve. You'll see:

✓ Authorized as @your-handle.
bot online

Credentials are cached in ~/.localhostdevs/credentials.json. Future runs skip the browser.

Already have an API key? Set LHD_API_KEY and the SDK uses it directly:

LHD_API_KEY=lhd_live_xxx node app.js
5

Try a command

Open your bot's chat page in the marketplace (/bots/your-handle/chat) and send ping. You should get pong back instantly. Stop your bot with Ctrl+C— the marketplace marks it offline after ~30 seconds of no heartbeats.

Coding with AI?

Copy the markdown below and paste it into your AI alongside your bot idea. It includes everything the model needs to scaffold a working bot — package, API surface, auth flow, rules.

AI prompt — paste into Claude, ChatGPT, or your editor's AI
I want to build a bot for **localhostdevs**, a platform where my bot runs on my own laptop and connects to their hosted gateway over a WebSocket. Consumers send commands through the marketplace's chat UI; my bot replies.

## SDK

Package: `@localhostdevs/sdk` (current major: 0.3). Install:

```bash
npm install @localhostdevs/sdk
```

## Project setup

The SDK is ESM-only and needs Node 20+. The project must set `"type": "module"`:

```bash
npm init -y
npm pkg set type=module
npm install @localhostdevs/sdk
```

## Minimal bot

```js
import { Bot } from '@localhostdevs/sdk';

// 'my-handle' must already exist on my dashboard at localhostdevs.com.
const bot = new Bot({ id: 'my-handle' });

bot.cmd('ping', async (ctx) => {
  await ctx.reply({ text: 'pong' });
});

bot.cmd('greet', async (ctx) => {
  const name = ctx.args.name ?? 'world';
  await ctx.reply({ text: `hello, ${name}` });
});

await bot.connect();
console.log('online');
```

## API surface

- `new Bot({ id, apiKey?, serverUrl?, apiBaseUrl?, idempotencyLruSize? })`
- `bot.cmd(name, handler)` — register handlers BEFORE `connect()`
- `bot.connect(): Promise<void>` — opens WS, identifies, awaits HELLO frame
- `bot.disconnect(): Promise<void>`

`handler` receives a `ctx` object:

- `ctx.command: string`
- `ctx.args: Record<string, unknown>` — parsed from the consumer's chat input
- `ctx.msgId: string` — unique invocation id
- `ctx.reply(body)` — call EXACTLY once. `body` shape:
  ```ts
  {
    text?: string;
    data?: unknown;
    dispatch?: { to: string; command: string; args?: Record<string, unknown> };
  }
  ```
  `dispatch` chains the response into another bot (single-dispatch only in v1).

## Streaming progress (optional)

For commands that take a while, opt into streaming and declare the
mimeType so the platform can advertise it:

```js
bot.cmd('analyze', { streaming: true, mimeType: 'image/png' }, async (ctx) => {
  await ctx.update({ progress: 50, text: 'halfway' });
  await ctx.reply({ data: bytes, mimeType: 'image/png' });
});
```

- `progress`: number (percent) | `{ current, total }` | `{ label }`
- `mimeType` on cmd opts: advertising-only; consumers see it on the bot detail page.
- Requires `@localhostdevs/sdk@^0.4.0`.

## Auth (no env var needed for local dev)

On first `node app.js`, the SDK opens a browser to authorize. After I click Approve, credentials cache to `~/.localhostdevs/credentials.json` (per bot handle). Subsequent runs skip the browser.

For non-interactive contexts (CI, headless boxes), pass the key directly:

```js
new Bot({ id: 'my-handle', apiKey: process.env.LHD_API_KEY });
```

## What I want you to build

<describe your bot idea here, e.g. "a price-bot that exposes a `price` command taking { ticker: string } and replies with the latest stock price from a public API.">

## Rules

- Entry point: `app.js` (or `app.ts` if you set up TS).
- Use `async`/`await` throughout.
- ONE `ctx.reply()` per command — calling it twice throws.
- Don't write your own heartbeat loop; the SDK handles it.
- Don't hardcode secrets — read from `process.env`.
- Keep it minimal — no Express, no extra ports, no Docker. The SDK is the only thing that opens a network connection.

Tip: after the AI generates your bot, you still need to (1) create the bot on your dashboard with a matching handle, and (2) run node app.js from a real terminal so the browser-based auth flow can fire.

SDK reference

The whole surface, on one page. Package: @localhostdevs/sdk.

new Bot(opts)

Construct a bot. Does not open the connection — call connect() for that.

FieldTypeNotes
idstring (required)Your bot handle. Lowercase alphanumeric + dashes. Must match a bot you own.
apiKeystringPer-bot key. Falls back to LHD_API_KEY env, then cached credentials, then interactive SSO.
serverUrlstringWebSocket gateway. Default: wss://bot-api.localhostdevs.com/bot. Override via LHD_SERVER_URL env.
apiBaseUrlstringHTTP API base for SSO flow. Default: https://localhostdevs.com. Override via LHD_API_BASE_URL env.
idempotencyLruSizenumberDedup cache size for repeated msgIds. Default: 1024.
bot.cmd(name, handler)

Register a command handler. Call before connect(). Duplicate names throw.

bot.cmd('greet', async (ctx) => {
  // ctx.command  — 'greet'
  // ctx.args     — parsed args from the consumer
  // ctx.msgId    — unique id for this invocation
  await ctx.reply({ text: `Hello, ${ctx.args.name ?? 'world'}!` });
});

Reply body supports three optional fields:

FieldTypeNotes
textstringPlain text reply.
dataunknownStructured payload (free-form JSON).
dispatch{ to, command, args? }Chain into another bot. Single-dispatch only in v1; original consumer is billed.
await ctx.replyList(items, { header? }) (0.5.0+)

Reply with a scrollable list — each item renders as a row in the chat UI. One reply per command, exactly like ctx.reply().

bot.cmd('headlines', async (ctx) => {
  await ctx.replyList(
    [
      { label: 'AP', title: 'Markets rally on rate news', secondary: '2m', href: 'https://example.com/a' },
      { label: 'Reuters', title: 'Chip demand keeps climbing', secondary: '14m', href: 'https://example.com/b' },
    ],
    { header: 'Top stories' },
  );
});
FieldTypeNotes
titlestring (required)Primary line of the row.
labelstringSmall badge on the left, e.g. a source name.
secondarystringTrailing text, e.g. a relative timestamp.
hrefstringOptional click-through URL.
await ctx.replyTable({ headers, rows }) (0.5.0+)

Reply with a table. rows is an array of rows, each an array of cells (string | number | null) lined up with headers.

bot.cmd('prices', async (ctx) => {
  await ctx.replyTable({
    headers: ['Ticker', 'Price', 'Change'],
    rows: [
      ['AAPL', 231.4, '+1.2%'],
      ['MSFT', 502.1, '-0.4%'],
    ],
  });
});
await ctx.replyImage(image, { mimeType?, alt?, text? }) (0.10.0+)

Reply with an inline image. image accepts raw bytes (Buffer / Uint8Array), a base64 string, or a full data: URL. Only raster types (png / jpeg / gif / webp) — SVG is rejected because inline SVG can run script.

bot.cmd('qr', async (ctx) => {
  const png = await makeQrPng(ctx.args.text ?? 'hello'); // returns a Buffer
  await ctx.replyImage(png, { mimeType: 'image/png', alt: 'QR code' });
});
FieldTypeNotes
mimeTypestringimage/png · image/jpeg · image/gif · image/webp.
altstringAccessible description of the image.
textstringOptional caption shown with the image.
await ctx.update({ progress?, text?, data?, mimeType? })

Send an intermediate progress frame. Only available when the bot opts into streaming via new Bot({ id, streaming: true }) or per-command bot.cmd(name, { streaming: true }, handler). Multiple calls allowed; ctx.reply() marks the dispatch complete.

bot.cmd('analyze', { streaming: true }, 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,
  });
});

progress accepts a number (0–100 percent), { current, total } for step counters, or { label: string } for indeterminate progress (renders as a spinner + label in the chat UI).

Requires SDK @localhostdevs/sdk@^0.4.0. See /docs/api for the consumer side.

bot.cmd(name, { mimeType }, handler) (0.4.1+)

Declare the mimeType this command's reply returns. Shown on the bot detail page so consumers know what to expect.

bot.cmd('chart', { streaming: true, mimeType: 'image/png' }, async (ctx) => {
  await ctx.update({ progress: 50 });
  await ctx.reply({ data: chartBytes, mimeType: 'image/png' });
});

Advertising only — the actual mimeType on the wire is whatever ctx.reply({ mimeType }) passes at runtime.

await ctx.replyButtons(text, buttons) (0.11.0+)

Reply with clickable buttons. A click is a new, separate dispatch — it does not resume the command that showed the buttons. Route each click to a hidden handler registered with bot.action. Each button is { label, action, args?, style?, charge? } — style is 'primary' | 'danger' | 'default'.

bot.cmd('deploy', async (ctx) => {
  await ctx.replyButtons('Ship to production?', [
    { label: 'Deploy', action: 'do-deploy', args: { go: true }, style: 'primary' },
    { label: 'Cancel', action: 'do-deploy', args: { go: false }, style: 'default' },
  ]);
});

bot.action('do-deploy', async (ctx) => {
  await ctx.reply({ text: ctx.args.go ? 'Deploying…' : 'Cancelled.' });
});

Clicks are free by default; pass charge: true on a button to bill it like a command. ctx.user?.id identifies who clicked.

await ctx.askYesNo(text, { action }) · askChoice(text, { action, options }) (0.11.0+)

Shorthands over replyButtons. askYesNo dispatches its action with { answer: 'yes' | 'no' }; askChoice renders one button per option and dispatches { choice: '<option>' }. Any args you pass are merged into the click dispatch.

bot.cmd('reset', async (ctx) => {
  await ctx.askYesNo('Wipe all data?', { action: 'do-reset' });
});
bot.action('do-reset', async (ctx) => {
  await ctx.reply({ text: ctx.args.answer === 'yes' ? 'Wiped.' : 'Kept.' });
});

bot.cmd('size', async (ctx) => {
  await ctx.askChoice('Pick a size', { action: 'pick-size', options: ['S', 'M', 'L'] });
});
bot.action('pick-size', async (ctx) => {
  await ctx.reply({ text: `You picked ${ctx.args.choice}.` });
});
bot.action(id, handler) (0.11.0+)

Register a HIDDEN handler — same shape as bot.cmd, but it never appears in the command list. It runs only when a consumer clicks a button whose action matches id, and replies exactly once just like a command.

await bot.connect()

Opens the WebSocket, sends IDENTIFY, waits for HELLO from the gateway, starts the heartbeat loop. Resolves once the bot is ready to receive commands.

await bot.disconnect()

Closes the WebSocket cleanly. After ~30s of silence the marketplace marks your bot offline.

Common issues

Authorization timed out
You waited too long to click Approve in the browser. Run node app.js again to start a fresh flow.
Invalid id or apiKey
The bot handle in your code doesn't match a bot in your dashboard, or the cached credentials in ~/.localhostdevs/credentials.json are stale. Delete the file and run again — it'll re-auth.
Connection refused / WebSocket failed
Check that the gateway is reachable from your machine: curl https://bot-api.localhostdevs.com/health. If it returns { ok: true }, you're fine; check your firewall otherwise.
Bot showing as offline in the marketplace
Make sure your bot process is still running. The gateway marks bots offline after ~30 seconds without heartbeats. The SDK handles heartbeats automatically while connected.
SyntaxError: Cannot use import statement outside a module
Run npm pkg set type=module in your project directory. The SDK is ESM-only.