npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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/sdk

Quickstart

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.js

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.

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.js

If 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 enable ctx.update()
  • streaming: true inside the command definition to flip the flag per command
  • ctx.update({ progress?, text?, data?, mimeType? }) — send 1+ intermediate frames before ctx.reply()
  • mimeType on ctx.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