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.
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.
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/sdkThe SDK is ESM-only — the type=module line matters. Node 20+.
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');Run it
node app.jsOn 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 onlineCredentials 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.jsTry 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.
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.
Construct a bot. Does not open the connection — call connect() for that.
| Field | Type | Notes |
|---|---|---|
| id | string (required) | Your bot handle. Lowercase alphanumeric + dashes. Must match a bot you own. |
| apiKey | string | Per-bot key. Falls back to LHD_API_KEY env, then cached credentials, then interactive SSO. |
| serverUrl | string | WebSocket gateway. Default: wss://bot-api.localhostdevs.com/bot. Override via LHD_SERVER_URL env. |
| apiBaseUrl | string | HTTP API base for SSO flow. Default: https://localhostdevs.com. Override via LHD_API_BASE_URL env. |
| idempotencyLruSize | number | Dedup cache size for repeated msgIds. Default: 1024. |
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:
| Field | Type | Notes |
|---|---|---|
| text | string | Plain text reply. |
| data | unknown | Structured payload (free-form JSON). |
| dispatch | { to, command, args? } | Chain into another bot. Single-dispatch only in v1; original consumer is billed. |
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' },
);
});| Field | Type | Notes |
|---|---|---|
| title | string (required) | Primary line of the row. |
| label | string | Small badge on the left, e.g. a source name. |
| secondary | string | Trailing text, e.g. a relative timestamp. |
| href | string | Optional click-through URL. |
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%'],
],
});
});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' });
});| Field | Type | Notes |
|---|---|---|
| mimeType | string | image/png · image/jpeg · image/gif · image/webp. |
| alt | string | Accessible description of the image. |
| text | string | Optional caption shown with the image. |
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.
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.
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.
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}.` });
});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.
Opens the WebSocket, sends IDENTIFY, waits for HELLO from the gateway, starts the heartbeat loop. Resolves once the bot is ready to receive commands.
Closes the WebSocket cleanly. After ~30s of silence the marketplace marks your bot offline.
Common issues
node app.js again to start a fresh flow.~/.localhostdevs/credentials.json are stale. Delete the file and run again — it'll re-auth.curl https://bot-api.localhostdevs.com/health. If it returns { ok: true }, you're fine; check your firewall otherwise.npm pkg set type=module in your project directory. The SDK is ESM-only.