CROWBOTDOCS
CHAT LLMS.TXT DISCORD LOG IN

API reference

crowbot speaks the OpenAI chat-completions dialect. Anything that can talk to OpenAI can talk to crowbot by changing two settings: the base URL and the API key.

base URL   https://api.crowbot.sh/v1
auth       Authorization: Bearer <your 16-digit account number>

Your account number is your API key. There is no separate key to generate. Spaces are accepted, so you can paste it exactly as it was shown to you.

Where things live

Three origins, each serving only its own paths; a path asked of the wrong one is a plain 404.

origin serves
https://api.crowbot.sh /v1/*, /api/me and /api/plans — what an agent configures and reads
https://chat.crowbot.sh the web chat, signup, funding (/api/funding, /checkout), plans, pairing
https://crowbot.sh these docs, /llms.txt, the installer

GET https://api.crowbot.sh/api/me returns site and api, so a client given one origin can find the others.

Endpoints

method path origin auth what
POST /v1/chat/completions https://api.crowbot.sh key, paid balance chat completions — streaming, tool calling, reasoning_effort
GET /v1/models https://api.crowbot.sh none live models with limits and per-direction rates
POST /v1/tools/codesearch https://api.crowbot.sh key, funded account run a codesearch tool call server-side; see Code search
GET /api/me https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie balance, spend, request count, key hint, can_spend, the active pass (plan, scope, ends_at, allowance_bp, refills) or null, the balance-fallback switch, and the site/api origins
POST /api/me/balance-fallback https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie body {"on": bool}: whether the balance pays once a plan's allowance is spent
GET /api/me/usage https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie this account's own spend, by model and by day, over days (default 30, 0 is all time): {days, models: [{model, label, requests, prompt_tokens, completion_tokens, cached_tokens, spent_usd}], series: [{day, requests, spent_usd}]}
GET /api/me/usage/log https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie one row per request, paged 50 at a time. page, sort (ts/model/tokens/cost), dir (asc/desc), days. totals covers the whole window, never the page
GET /api/me/payments https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie this account's payments, newest first {payments: [{at, usd, kind}]}
GET /api/me/keys https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie the keys on this account (paired machines)
POST /api/me/keys/{ref}/revoke https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie revoke one key by its ref; the key you are using is refused (409)
POST /api/me/delete https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie close this account for good; body {account_number}; any balance is forfeited
POST /api/pair/start https://api.crowbot.sh, https://chat.crowbot.sh none (per-address cap) open a pairing: {device_code, user_code, ttl}; body {"hostname"} optional
GET /api/pair/{device_code} https://api.crowbot.sh, https://chat.crowbot.sh none (per-address cap) poll: 202 pending, 200 {status: ready, api_key} once, 410 expired
GET /pair https://chat.crowbot.sh none the page a person types the user_code into
POST /pair https://chat.crowbot.sh chat cookie the same page, showing Approve for a typed code
POST /api/pair/approve https://api.crowbot.sh, https://chat.crowbot.sh chat cookie approve {user_code} (mints a key for that machine) or {user_code, deny: true}
POST /api/signup https://chat.crowbot.sh none + X-Pow (kind signup) mint an account: {account_number, formatted, api_base}
GET /api/pow https://chat.crowbot.sh none for ?kind=signup; key or cookie for chat a proof-of-work challenge {challenge, bits, ttl}
POST /api/chat https://chat.crowbot.sh key or chat cookie + X-Pow the web chat's own streaming door; agents use /v1/chat/completions
GET /api/models https://chat.crowbot.sh none the chat picker's model list (same rows as /v1/models)
POST /api/me/billing-portal https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie a session in the processor's portal for the cards it keeps for you {url}
GET /api/live https://chat.crowbot.sh none server-sent events: each live topic (?topics=tokens) now, then on every change
GET /api/live/{topic} https://chat.crowbot.sh, https://crowbot.sh none a live topic's latest payload; tokens is {total}, every token served, all time
POST /api/tools/codesearch https://chat.crowbot.sh key, funded account /v1/tools/codesearch, reachable from the chat origin
GET /api/funding https://chat.crowbot.sh none what a top-up may be and how to pay for it {min_usd, max_usd, pay_with, rails}
GET /api/plans https://api.crowbot.sh, https://chat.crowbot.sh none, or key or chat cookie the plans on sale to this account; {plans: []} while none is
POST /api/plans/{plan_id}/buy https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie buy that plan's pass with credit already in the wallet; {ok, pass_id}, 402 when short
GET /api/me/subscription https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie the renewing agreement this account holds, or {subscription: null}
POST /api/me/subscription/cancel https://api.crowbot.sh, https://chat.crowbot.sh key or chat cookie stop renewing; the pass already paid for runs to its end
GET /checkout https://chat.crowbot.sh key or chat cookie ?usd=N for credit, ?plan=ID for a pass, &renew=1 for one that renews → {checkout_url} (JSON) or a 303 to the processor (browser)
GET /paid https://chat.crowbot.sh none where the processor sends a person back after paying
GET / https://chat.crowbot.sh, https://crowbot.sh none the landing page on the site origin, the web chat on the chat origin
GET /install.sh https://crowbot.sh none the opencode installer (bash)
GET /install.ps1 https://crowbot.sh none the opencode installer (PowerShell, Windows)
GET /llms.txt https://crowbot.sh none this document
GET /llms-full.txt https://crowbot.sh none every docs page inlined
GET /docs https://crowbot.sh none 301 to the first docs page
GET /docs/{name} https://crowbot.sh none one docs page, rendered for people and search engines
GET /robots.txt https://crowbot.sh none what search engines may crawl, and where the sitemap is
GET /sitemap.xml https://crowbot.sh none the public pages, from content/pages.yaml
GET /docs/{name}.md https://crowbot.sh none one docs page as markdown
GET /docs/opencode.json https://api.crowbot.sh, https://crowbot.sh none generated opencode provider config
GET /docs/opencode-plugin.js https://api.crowbot.sh, https://crowbot.sh none the plugin the installer drops in
POST /webhooks/payment https://crowbot.sh processor HMAC the crypto processor's callback
POST /webhooks/stripe https://crowbot.sh Stripe signature the card processor's callback
GET /healthz every origin none liveness, on every host
GET /privacy https://crowbot.sh none the Privacy page
GET /terms https://crowbot.sh none the Terms page
GET /refunds https://crowbot.sh none the Refunds page
GET /why https://crowbot.sh none the Why it's cheap page

/v1/chat/completions is a passthrough: your system message is sent to the model unchanged and stays first, and so are your tools, tool_choice and response_format. Your system prompt is never replaced, edited, reordered or moved behind anything of ours.

Some models add a short instruction block of their own alongside it — formatting or refusal style. Your prompt keeps its exact bytes and its position; ours goes after it.

Request

json{
  "model": "gpt-6-astra",
  "messages": [
    {"role": "system", "content": "You are a careful assistant."},
    {"role": "user", "content": "Hello"}
  ],
  "max_tokens": 1024,
  "stream": true
}

An unrecognised model falls back to the default rather than erroring, because agentic clients often send names from other providers.

A model id we do recognise but are not serving returns 404 model_not_found and lists the live ids: falling back there would bill you for a model you did not ask for.

Sampling parameters are forwarded as you send them, including temperature and top_p. Which of them a model honours is up to its vendor.

reasoning_effort we do interpret. Send low, medium, high or max: it overrides the model's configured default, a model without that tier gets its own ceiling (max reaches a vendor with three tiers as high), and a value none can honour is a 400 naming what the model accepts. Reasoning tokens bill as output, so this is a spend control, not a quality slider.

Streaming

Set stream: true and read Server-Sent Events. Chunks are chat.completion.chunk objects terminated by data: [DONE], exactly as OpenAI emits them. Usage arrives on a final chunk with an empty choices array — you do not need to ask for it.

Reasoning models put their thinking trace in choices[].delta.reasoning_content, alongside the usual content. Clients that don't know the field simply ignore it.

Tool calling

Standard OpenAI shape in both directions:

json{
  "tools": [{"type": "function", "function": {
    "name": "bash",
    "description": "Run a shell command",
    "parameters": {"type": "object",
                   "properties": {"command": {"type": "string"}},
                   "required": ["command"]}
  }}],
  "tool_choice": "auto"
}

Replies carry message.tool_calls[] with function.arguments as a JSON string, and finish_reason: "tool_calls". Return results as {"role": "tool", "tool_call_id": …, "content": …} messages — one per call, as OpenAI expects. Parallel calls work; send every result before the next turn.

Streaming tool calls arrive as delta.tool_calls[] fragments. Concatenate function.arguments per index to rebuild the JSON.

codesearch runs on our side: give it a query, get matching code from public repositories. No search account of your own.

POST https://api.crowbot.sh/v1/tools/codesearch
Authorization: Bearer <your account number>

query is required; lang, repo and count are optional. query may itself carry search filters (repo:, file:, lang:, patterntype:regexp, patterntype:structural, …), which compose with the fields; count is capped.

json{"query": "epoll_wait", "lang": "c", "count": 5}

The reply is {"query", "results", "truncated"}, each result carrying repo, path, the first matching line, a url to the source on its own host, and a snippet (for a type:symbol query, the symbol's kind, name and line):

json{"results": [{"repo": "github.com/x/y", "path": "loop.c", "line": 42,
              "url": "https://github.com/x/y/blob/<ref>/loop.c#L42",
              "snippet": "int n = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);"}],
 "truncated": false}

Write queries the way code is written: exact identifiers find code, sentences rarely do. lang narrows by language; repo as org/name is anchored to that one repository, which turns a pile of forks into the canonical file. A backend rate limit comes back as 429 rate_limited with Retry-After.

Wiring it into an agent. Register the tool below; when the model calls it, POST the arguments to the endpoint above and feed the reply back as the tool result.

json{
  "type": "function",
  "function": {
    "name": "codesearch",
    "description": "Search public source code and get back matching snippets with repo, path and line. Use it when a real implementation answers better than a description. Write the query the way code is written, not the way a question is asked: two or three exact identifiers (function, class, option or constant names) find code; sentences almost never do. Set lang when you know the language. Set repo when you know the project - org/name is enough - which turns a pile of forks into the one canonical file. Quote an exact phrase \"like this\" to match it verbatim; add type:symbol to find where a name is defined rather than used (slower, but finds the defining file even when the canonical repo is not indexed); add patterntype:regexp for a pattern. If nothing comes back, or the repository is reported as not found, drop the repo filter or a term and search again.",
    "parameters": {
      "type": "object",
      "properties": {
        "query": {
          "type": "string",
          "description": "Identifiers and exact phrases, optionally with file:/type:symbol/patterntype:regexp filters"
        },
        "lang": {
          "type": "string",
          "description": "Language filter, e.g. python, c++, typescript, go"
        },
        "repo": {
          "type": "string",
          "description": "Repository: org/name (anchored to that repo) or a regexp over full names like github.com/org/name"
        },
        "count": {
          "type": "integer",
          "description": "Maximum results, capped by the server"
        }
      },
      "required": [
        "query"
      ]
    }
  }
}

Snippets bill as ordinary input tokens on your next turn; there is no search fee. Calls are rate-limited. This is the same definition the web chat gives the model.

Errors

Errors are {"error": {"message": …, "type": …}}. The type is the field to branch on — not the status, because a status can be ambiguous.

status type meaning
400 bad_request the body was not a JSON object, or a parameter is invalid
400 bad_amount /checkout: not an amount to the cent within min_usd..max_usd
400 bad_ref /api/me/keys/{ref}/revoke: not a key reference
400 number_mismatch /api/me/delete: the account number typed is not this account's
400 cannot_renew /checkout?renew=1: the chosen payment method cannot hold a card to charge again
401 invalid_api_key account number unknown or revoked
403 account_disabled the account is frozen (a card chargeback or refund); it can read its status but not spend or add funds
402 insufficient_balance out of funds, or this request's input alone costs more than the balance — top up
402 plan_allowance_spent the plan's allowance is spent and this account is set not to fall back on its balance
404 model_not_found a model we know but are not serving; the message lists the live ids
404 not_found no such page, key or route on this host
404 no_such_plan the plan named is not on sale to this account
404 no_subscription /api/me/subscription/cancel: this account has no renewing plan
409 cannot_revoke_current that key authenticated this request; revoke it from another
409 subscription_active /api/me/delete: a renewing plan is still active; cancel it first
410 pair_expired the pairing code expired or was already used
413 body_too_large over the 16 MiB request-body ceiling
428 pow_required proof of work missing or expired (chat and signup only, never /v1)
429 rate_limited back off; honour Retry-After when present
502 tool_error a tool (e.g. codesearch) failed upstream; the message says how
502 checkout_failed the payment processor did not answer
502 cancel_failed the payment processor did not answer a cancellation; retry shortly
404 no_customer /api/me/billing-portal: no card is saved yet; pay by card once
502 portal_failed /api/me/billing-portal: the payment processor did not answer; retry shortly
503 live_full /api/live: every live stream is taken; poll /api/live/{topic} and retry after Retry-After
503 live_warming /api/live/{topic}: the topic has not been read since the app started; retry shortly
503 tool_unavailable a tool is not configured or is temporarily off
503 checkout_unconfigured payments are not configured on this deployment
503 no_model nothing is live right now
503 no_provider that model's upstream is not configured
503 no_price a feed-priced model with no quote yet — retry shortly
503 price_fault a feed-priced model whose quote moved past our ceiling; off sale until a sane one lands
503 upstream_unavailable our problem, not yours — try another model
vendor's upstream_error the upstream's own reason for refusing, with its Retry-After if it sent one

Every /v1/chat/completions answer, error or not, carries an X-Request-Id header. Quote it when reporting a failure: it names the one record we keep of that call.

A streamed reply may open with an SSE comment line (:) and carry one during any pause: a keepalive, which every SSE client ignores. A failure after the stream has opened arrives as that same {"error": {"message": …, "type": …}} envelope in a data: frame, followed by data: [DONE].

An upstream vendor's own 401/402/403 is never relayed to you as-is. If our account with a vendor lapses you get a 503 upstream_unavailable: the fault is ours, not your key's or balance's.

Billing

Input, cached input and output bill at separate per-million rates — see Models. Every token sent upstream is billed, and cached input is discounted at the same ratio the upstream gives us.

Two things can pay for a request, and they are asked in a fixed order. A pass — what buying a plan gives you, see Plans — pays first, for any model in its scope. Your balance pays for everything else: a model the pass does not cover, or every request when you hold no pass. The two never mix inside one request; whichever said yes at the door settles it.

A pass does not bill at the rates above. It carries an allowance, sized from what the plan cost, that requests draw down at what the model costs us and that refills continuously at the plan's rate — so it absorbs a burst and comes back over the day. Once the allowance cannot cover a request, that request is the balance's, unless you have said otherwise: POST /api/me/balance-fallback with {"on": false} caps you at the plan, and a request that would then need the balance is refused with 402 plan_allowance_spent and a message saying roughly when the allowance is back. GET /api/me shows the pass while you hold one: allowance_bp is how much of the allowance is left, in basis points of the whole, and refills says roughly when it is full again.

Non-streamed responses carry X-Balance-Microdollars with your remaining paid balance in microdollars (1e-6 USD); a request the pass paid for leaves it where it was. A streamed response does not carry it — headers are sent before the body starts, so the figure would be stale before the first token. Read /api/me after a stream, or use the usage block on the final chunk.

Every request is sized to whatever is paying for it before it is sent upstream: max_tokens (or max_completion_tokens) is capped to what the pass's remaining allowance or the balance can pay for after the estimated input, and a request whose input alone costs more than that is refused with 402 insufficient_balance before any vendor is called. Concurrency follows the payer too — a balance gets as many parallel requests as it covers in worst-case requests, a pass at most the slots its plan sold, one at minimum either way — and past that a 429 rate_limited with Retry-After.

Plans

A plan is one price for a fixed number of days. Buying one gives the account a pass: an allowance that pays first for every model in the plan's scope, the way Billing describes, while the balance goes on paying for everything else. What is on sale is public:

GET https://chat.crowbot.sh/api/plans
Authorization: Bearer <your account number>     (optional)

The reply is {"plans": [...]}, one entry per plan you may buy:

json{"id": "starter", "label": "Starter", "tagline": "Hard working or hardly working?",
 "price_usd": 15, "days": 30, "concurrency": 2, "scope": ["frontier"],
 "features": ["Every frontier model", "Refills every day", "Cancel any time"], "badge": null}

price_usd buys days days. concurrency is how many requests the pass runs at once; scope names the directions on the Models page it covers (frontier is the resold frontier models, crow our own). features and badge are the card's own words. A pass fixes its allowance in money when it is bought, so nothing on a plan moves when a vendor reprices.

The list is what your account may buy. Signed out, it is whatever is on general sale; while plans are in a closed beta it is empty unless your account is in it. {"plans": []} means nothing is on sale to you, not that the call failed.

Funding, programmatically

The chat UI does all of this with buttons; an agent can do it with four calls, all on https://chat.crowbot.sh except the last.

  1. Create an account. GET https://chat.crowbot.sh/api/pow?kind=signup returns {"challenge", "bits", "ttl"}. Find a nonce (≤ 40 chars) such that sha256("<challenge>:<nonce>") has at least bits leading zero bits, then POST https://chat.crowbot.sh/api/signup with header X-Pow: <challenge>.<nonce>. The reply is {"account_number", "formatted", "api_base"} — the only time the number is ever shown. Each signup from an address raises the next one's bits for a day, and signups are capped per address; a 428 means the work grew meanwhile: fetch again.
  2. Pick an amount. GET https://chat.crowbot.sh/api/funding → {"min_usd", "max_usd", "pay_with", "rails"} (rails: [{"name", "label", "renews"}]). Any amount in range is accepted, to the cent (usd=3.50), credited 1:1.
  3. Get an invoice. GET https://chat.crowbot.sh/checkout?usd=<amount> with Authorization: Bearer <number> returns {"checkout_url"} (a browser Accept: text/html request is redirected there instead). Payment is by crypto or card; crypto settles in stablecoin.
  4. Confirm. There is no payment-status endpoint. The processor's callback credits the wallet; poll GET https://api.crowbot.sh/api/me until balance_microdollars rises. Balances never expire.

Device pairing and keys

A machine can hold its own key, so a laptop can be revoked later without changing the account number. This is what the installer does; the raw flow:

  1. POST https://chat.crowbot.sh/api/pair/start (optional body {"hostname": "…"}) → {"device_code", "user_code", "ttl"}. Unauthenticated, capped per address.
  2. Show the person the user_code and send them to https://chat.crowbot.sh/pair, where they type it and press Approve while signed in (programmatically: POST https://chat.crowbot.sh/api/pair/approve with the chat cookie and {"user_code"}, or {"user_code", "deny": true}).
  3. Poll GET https://chat.crowbot.sh/api/pair/<device_code>: 202 {"status": "pending"} while waiting, then 200 {"status": "ready", "api_key"} exactly once, then 410 pair_expired. Codes last 15 minutes.

GET /api/me/keys lists the account's live keys with a ref, name, hint, created_at and whether each is the one making the request; POST /api/me/keys/<ref>/revoke revokes one. The key that authenticated the request cannot be revoked (409 cannot_revoke_current): there is no recovery, so that click would be a permanent lockout.