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.
- Go to /account/tokens (signed-in users only).
- Type a label (e.g.
my-claude-config) so you can revoke this specific key later without nuking everything. - Click Generate new key. The plaintext appears once — it starts with
lhduser_. Copy it now; you won't see it again.
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.
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 withevent: update | final | errorframes. Read via the browser'sEventSource(note: POST endpoints needfetch()+reader, not EventSource).Accept: application/x-ndjson— one JSON per line with{"kind":"update"|"final"|"error", …}. Easiest from non-browser callers.
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
foooand the bot's handler isfoo, the bot won't reply and you'll get a504 timeoutafter 10s. UseGET /api/v1/bots/{handle}/commandsto discover what's available. argsis 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_creditsif 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
429with aRetry-Afterheader 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. CheckGET /api/v1/bots/{handle}first — thestatusfield 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).
/api/v1/me— Who is this token?Returns { userId, userHandle, tokenKind: 'api_key' | 'app_token', tokenLabel }. Useful for sanity-checking a key works before depending on it.
/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.
/api/v1/bots/{handle}— One bot's metadataReturns { 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.
/api/v1/bots/{handle}/commands— Commands the bot has registeredReturns { commands: [{ name, description, args }] }. The args object follows JSON Schema; use it to build a UI or validate before sending.
/api/v1/bots/{handle}/dispatch— Run a command on the botBody: { 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).
/api/v1/auth/exchange— Trade a Connect code for an app tokenno auth headerSee /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"
}
}| Status | code | When you see it |
|---|---|---|
| 401 | unauthorized | No, malformed, expired, or revoked token |
| 403 | forbidden | Caller can't access this bot (private, paused, or owned by someone else and not listed) |
| 404 | not_found | Bot handle does not exist |
| 400 | validation_error | Missing command, malformed body, or args fail the bot's schema |
| 402 | insufficient_credits | Paid bot, your balance is below the per-call price |
| 429 | rate_limited | Over 60 req/min for this token. Check Retry-After header |
| 503 | bot_offline | Bot is not connected to the gateway |
| 504 | timeout | Bot is connected but didn't reply within 10 seconds |
| 500 | internal_error | Gateway, 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.