Public API

Call bots from your code.

Bearer-token REST API. Three steps from zero to a dispatched command: grab a key, pick a bot, POST a command. The bot's reply comes back in the response body.

1. Get an API key

Persistent keys live on your account and don't expire — they're the easiest way to call the API from scripts, cron jobs, or your laptop.

  1. Go to /account/tokens (signed-in users only).
  2. Type a label (e.g. my-claude-config) so you can revoke this specific key later without nuking everything.
  3. Click Generate new key. The plaintext appears once — it starts with lhduser_. Copy it now; you won't see it again.
Heads up. Treat the key like a password. If it leaks, revoke it from /account/tokens and generate a new one. Compromised keys can dispatch commands as you — including paid ones that debit your credit balance.

2. Pick a bot

You can call any bot you own (any status, including drafts) or any bot that's published in the marketplace.

Bots are referenced by their handle — the lowercase string after /bots/ in the URL (e.g. @price-bot → handle is price-bot).

Not sure what to call? Hit GET /api/v1/bots — it returns every bot accessible to your key.

3. Dispatch a command

The actual API call. Send the command name and any args the bot expects; the bot replies in-band.

curlNode / fetchPython
curl -X POST https://www.localhostdevs.com/api/v1/bots/<HANDLE>/dispatch \
  -H "Authorization: Bearer lhduser_XXXXX..." \
  -H "Content-Type: application/json" \
  -d '{"command":"ping","args":{}}'
const res = await fetch(
  `https://www.localhostdevs.com/api/v1/bots/${handle}/dispatch`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LHD_TOKEN}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ command: 'ping', args: {} }),
  },
);
const json = await res.json();
// { ok: true, msgId: '...', command: 'ping', latencyMs: 42, body: { text: 'pong' } }
import os, httpx
r = httpx.post(
    f"https://www.localhostdevs.com/api/v1/bots/{handle}/dispatch",
    headers={"Authorization": f"Bearer {os.environ['LHD_TOKEN']}"},
    json={"command": "ping", "args": {}},
)
print(r.json())

Successful response

{
  "ok": true,
  "msgId": "01J9KQ...",
  "command": "ping",
  "latencyMs": 42,
  "body": {
    "text": "pong"
  }
}

body shape is bot-defined. The standard fields the SDK emits are text, data, and dispatch — but a bot can return anything it wants. latencyMs is the round-trip time the gateway measured (server → bot → server).

4. Streaming responses (optional)

Long-running bots can stream progress frames before the final reply. Opt in with an Accept header; without one, you get the same single-JSON response as today.

Three transports, picked by the consumer:

  • Accept: application/json (default) — one final JSON response. Backward compatible with everything written before. Update frames are buffered server-side and never appear.
  • Accept: text/event-stream — SSE with event: update | final | error frames. Read via the browser's EventSource (note: POST endpoints need fetch()+reader, not EventSource).
  • Accept: application/x-ndjson — one JSON per line with {"kind":"update"|"final"|"error", …}. Easiest from non-browser callers.
curl (NDJSON)curl (SSE)Node fetch (NDJSON)Python (NDJSON)
curl -X POST https://www.localhostdevs.com/api/v1/bots/<HANDLE>/dispatch \
  -H "Authorization: Bearer lhduser_XXXXX..." \
  -H "Content-Type: application/json" \
  -H "Accept: application/x-ndjson" \
  --no-buffer \
  -d '{"command":"analyze","args":{}}'
curl -X POST https://www.localhostdevs.com/api/v1/bots/<HANDLE>/dispatch \
  -H "Authorization: Bearer lhduser_XXXXX..." \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  --no-buffer \
  -d '{"command":"analyze","args":{}}'
const res = await fetch(
  `https://www.localhostdevs.com/api/v1/bots/${handle}/dispatch`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.LHD_TOKEN}`,
      'Content-Type': 'application/json',
      'Accept': 'application/x-ndjson',
    },
    body: JSON.stringify({ command: 'analyze', args: {} }),
  },
);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  let i;
  while ((i = buf.indexOf('\n')) >= 0) {
    const line = buf.slice(0, i); buf = buf.slice(i + 1);
    if (!line) continue;
    const frame = JSON.parse(line);
    if (frame.kind === 'update') console.log('progress:', frame.progress);
    if (frame.kind === 'final')   console.log('done:', frame.body);
  }
}
import os, json, httpx
with httpx.stream(
    'POST',
    f"https://www.localhostdevs.com/api/v1/bots/{handle}/dispatch",
    headers={
        "Authorization": f"Bearer {os.environ['LHD_TOKEN']}",
        "Accept": "application/x-ndjson",
    },
    json={"command": "analyze", "args": {}},
) as r:
    for line in r.iter_lines():
        if not line: continue
        frame = json.loads(line)
        if frame["kind"] == "update":
            print("progress:", frame.get("progress"))
        elif frame["kind"] == "final":
            print("done:", frame["body"])

5. Frame shapes

Both transports emit JSON frames with the same fields — SSE wraps each in event: + data:, NDJSON puts each on a line.

Update frame

{
  "kind": "update",
  "progress": 60,
  "body": {
    "text": "Crunching…",
    "mimeType": "text/plain"
  }
}

progress may be a number (percent), an object { current, total }, or { label }. Bots may emit any number of update frames (or none) before the final reply. Inactivity timeout (10s without any frame) fails the dispatch.

Final frame

{
  "kind": "final",
  "ok": true,
  "msgId": "01J...",
  "command": "analyze",
  "latencyMs": 8421,
  "body": {
    "text": "done",
    "mimeType": "image/png",
    "data": "<base64-encoded bytes>"
  }
}

Error frame

{
  "kind": "error",
  "error": {
    "code": "timeout",
    "message": "bot did not respond in time"
  }
}

Things that bite people

Most issues calling the API trace back to one of these. Worth a glance before you start debugging.

  • Command must match what the bot registered. If you send fooo and the bot's handler is foo, the bot won't reply and you'll get a 504 timeout after 10s. Use GET /api/v1/bots/{handle}/commands to discover what's available.
  • args is the keyword-arg object. Whatever shape the bot expects, that's what goes in there. Send {} for commands that take no args.
  • Paid bots debit your credit balance. Free bots are free. You'll see 402 insufficient_credits if you're low — top up at /account/billing. Bots you own are always free for you, regardless of their listed price — no self-billing, no top-up needed for dev/test.
  • Rate limit: 60 req/min per token. Returns 429 with a Retry-After header on overflow. Higher limits available on request.
  • Bots can be offline. A bot only responds if its harness is running on the developer's machine. If it's offline you'll get 503 bot_offline. Check GET /api/v1/bots/{handle} first — the status field tells you.

All endpoints

The full surface. Every endpoint takes an Authorization: Bearer header except /auth/exchange (which trades a one-time code for a token — see the Connect docs).

GET/api/v1/meWho is this token?

Returns { userId, userHandle, tokenKind: 'api_key' | 'app_token', tokenLabel }. Useful for sanity-checking a key works before depending on it.

GET/api/v1/bots?q=&limit=&offset=List bots the caller can dispatch to (paginated)

Query params: q (case-insensitive substring on handle/name, max 200 chars), limit (1-100, default 50), offset (>=0, default 0). Returns { bots: [{ handle, name, description, status, pricePerMessageCredits, isListed }], total, limit, offset, hasMore }. Includes your own bots (any status) plus every listed bot in the marketplace.

GET/api/v1/bots/{handle}One bot's metadata

Returns { bot: { ..., status, supportsStreaming, commands } }. supportsStreaming indicates whether the bot opts into ctx.update() — false-flagged bots still accept stream Accept headers but only emit one final frame. The status is derived from the last heartbeat — 'online' is < 15s, 'stale' is < 30s, otherwise 'offline'. commands is an array of { name, streaming, mimeType: string | null } describing every command the bot has declared via the SDK (0.4.1+). Callers can use this to render a capability table or decide which commands to expose to end users.

GET/api/v1/bots/{handle}/commandsCommands the bot has registered

Returns { commands: [{ name, description, args }] }. The args object follows JSON Schema; use it to build a UI or validate before sending.

POST/api/v1/bots/{handle}/dispatchRun a command on the bot

Body: { command: string; args?: Record<string, unknown> }. Returns { ok: true, msgId, command, latencyMs, body } on success or an error envelope on failure. Send Accept: text/event-stream or application/x-ndjson to stream progress frames (see "Streaming responses" above).

POST/api/v1/auth/exchangeTrade a Connect code for an app tokenno auth header

See /docs/connect for the full SSO flow. Body: { code: string, app: string }. Returns { ok: true, token: 'lhdapp_…' }.

Error codes

On failure you get an error envelope — same shape across every endpoint.

{
  "ok": false,
  "error": {
    "code": "insufficient_credits",
    "message": "not enough credits to dispatch"
  }
}
StatuscodeWhen you see it
401unauthorizedNo, malformed, expired, or revoked token
403forbiddenCaller can't access this bot (private, paused, or owned by someone else and not listed)
404not_foundBot handle does not exist
400validation_errorMissing command, malformed body, or args fail the bot's schema
402insufficient_creditsPaid bot, your balance is below the per-call price
429rate_limitedOver 60 req/min for this token. Check Retry-After header
503bot_offlineBot is not connected to the gateway
504timeoutBot is connected but didn't reply within 10 seconds
500internal_errorGateway, broker, or DB failure. Retry — and tell us if it persists

Next: building an integration?

If you're shipping an app that calls bots on behalf of OTHER users (not just yourself), you want the Connect flow — an OAuth-style consent dance that issues per-user app tokens.