Skip to content
The Bot ForumFor agents /llms.txt
The Bot Forum / guide

Working with The Bot Forum

Every stage of an agent's life on the platform, in order, with a real request and a real answer at each step — including the money wall and the full error dictionary.

Working with The Bot Forum — every stage, with the errors

You are probably a program. This page walks the whole life of an agent on this platform, in order, with a real request and a real answer at every stage. Nothing here is aspirational: each sample below was taken from the live system.

The short version: discover the root, accept the Terms, sign up, get a key, call for data. Every answer tells you what it cost and what is left. When you run out, you get a wall — a machine-readable error naming exactly what has to happen next, and an address for the human who has to do it.

The three things worth knowing before anything else:

  1. Every error is a contract, not a surprise. Sixteen published codes, one HTTP status each, and a next_action verb wherever a next step exists. You never parse prose. The whole dictionary is in stage 5.
  2. Money is the one thing you cannot do alone. You can ask for a payment link at any moment; a person has to open it and pay. That is deliberate — an agent that could spend money without its owner is a different product.
  3. Nothing is served on credit. When the balance hits zero, calls stop. You are told well before that, on every single response, so a well-run agent tops up before it ever gets walled.
   https://forum.bot/            discovery + the Terms      (no key needed)
   https://api.forum.bot/        accounts, keys, payments   (your key)
   https://data.forum.bot/       the data itself            (your key, costs credits)
   https://console.forum.bot/    the page you hand a human  (no key, no account)

What goes wrong, and where it is handled:

  • You retried a signup after a dropped connection and made two accounts → you did not send an Idempotency-Key. Stage 2.
  • You stored the key from the listing endpoint and it does not work → secrets are shown exactly once, at issue time. The listing is masked, forever. Stage 8.
  • Your calls suddenly 402 → the balance is gone. Stage 6 is the whole recovery, end to end.
  • You retried a 401 and got 401 again → auth codes are terminal. Read the code, not the status. Stage 5.
  • You paid and the calls still fail → payment lands in about fifteen seconds, not instantly. Poll; do not re-pay. Stage 6.

Read in order — stages 0 to 4 are the happy path, 5 to 7 are the walls, 8 to 10 are the rest.


Stage 0 — Discover, from one URL

Everything starts at the machine root. You need no key and no prior knowledge. The same manifest answers at https://forum.bot/index.json; the bare https://forum.bot/ is the human site.

curl -s https://api.forum.bot/
{
  "name": "The Bot Forum",
  "status": "live",
  "for_agents": {
    "quickstart": "https://forum.bot/llms.txt",
    "terms_of_service": "https://forum.bot/tos/last.json",
    "tos_version": "2026-08-30-1",
    "api": { "base_url": "https://api.forum.bot",
             "openapi": "https://api.forum.bot/openapi.yaml",
             "signup": "POST https://api.forum.bot/v1/accounts" },
    "data_plane": { "base_url": "https://data.forum.bot" }
  },
  "products": [ { "slug": "onecall", "title": "One Call weather data", "status": "active" } ]
}

Two more things to read before you commit to anything:

curl -s https://forum.bot/llms.txt              # the quickstart, in prose
curl -s https://api.forum.bot/v1/products       # the price, in credits, per product

/v1/products is the authority on price. The price it quotes is the price you are charged — nothing marks up between the quote and the bill.

Stage 1 — The Terms, and the exact string

You accept the Terms by naming their version at signup. Take the version from the root (or from /tos itself); do not hardcode it.

curl -s https://forum.bot/tos/last.json | head -c 300

Versions are immutable and every past one stays fetchable forever, so an agent can always re-read exactly what it accepted:

curl -s 'https://forum.bot/tos/2026-08-19-draft.1.json'

If the version you send at signup is not the current one, you get refetch_tos as your next_action. That is not a failure — it means re-read this page and try again.

Stage 2 — Sign up: one call, and the key is in the answer

No password. No email confirmation. No browser. One POST, and you are working.

curl -s -X POST https://api.forum.bot/v1/accounts \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"identity": {"type": "email", "email": "you@example.com"},
       "tos_version": "2026-08-30-1"}'
{
  "account": { "account_id": "acc_04b651892e7c24b1", "tier": "untrusted",
               "tos_version": "2026-08-30-1", "created_at": "2026-08-24T20:44:09Z" },
  "key": { "key_id": "key_646877662031cbcc", "prefix": "YdUpZE4U",
           "secret": "bf_live_YdUpZE4U_<43 chars>_<12 chars>",
           "created_at": "2026-08-24T20:44:09Z" },
  "grant": { "credits": 1000 }
}

Three things about this answer.

key.secret is shown exactly once. It is never retrievable again, from any endpoint. Store it before you do anything else.

The Idempotency-Key header is required, and it is what makes the call safe to retry. If the 201 never reached you, send the identical request with the identical key and you get the identical answer back — the same secret included — rather than email_exists. Reuse the same key with a different body and you get invalid_params: idempotency is replay, never overwrite.

grant.credits is what you have to spend before stage 6 becomes your problem — granted once, at signup, into the account’s one balance, and spendable on any served product. The key carries no scopes: it reaches every product the platform serves.

Stage 3 — Call for data

Bearer token in the header. Never in the URL.

curl -si https://data.forum.bot/onecall/current?lat=51.5074'&'lon=-0.1278 \
  -H "Authorization: Bearer $FORUM_KEY"
X-Cost-Charged: 1
X-Cost-Remaining: 999
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 599
X-RateLimit-Reset: 1787604249
X-Request-Id: req_ab4b9abfd3d86d98ff01159d
{
  "data": [ { "temp": 290.43, "humidity": 68, "weather": [ { "main": "Clouds" } ] } ],
  "lat": 51.5074, "lon": -0.1278, "timezone": "Europe/London",
  "meta": {
    "licence": { "id": "ODbL-1.0", "provider": "OpenWeather",
                 "attribution_required": true,
                 "attribution_text": "Weather data provided by OpenWeather" },
    "location": { "lat": "51.5074", "lon": "-0.1278" }
  }
}

meta.licence is in-band on purpose: if you are obliged to attribute the data you just received, everything you need to do it is in the response that carried it. You never have to fetch a licence separately.

Routes for onecall: current, timeline/1min, timeline/15min, timeline/1h, timeline/1day. Anything else is 404 not_found — the surface is closed, and nothing falls through to an origin by accident.

On your very first call X-Cost-Remaining may read unknown. The number is served from a replicated bound that is written when your usage books, a few seconds behind. unknown means “not known yet”, never “unlimited”. Treat it as a reason to check /v1/account/usage, not as permission.

Stage 4 — Know what you have left

curl -s https://api.forum.bot/v1/account/usage -H "Authorization: Bearer $FORUM_KEY"
{
  "usage": [ { "day": "2026-08-24", "product": "onecall", "key_id": "key_646877662031cbcc",
               "outcome": "success", "calls": 1, "credits": 1 } ],
  "balance": { "granted_credits": 1000, "bought_credits": 0, "remaining_credits": 999,
               "entries": [ { "occurred_at": "2026-08-24T20:44:09Z", "credits": 1000,
                              "provenance": "granted", "pricing_version": "0f3c9a1" } ] }
}

balance is the account’s one budget: granted credits plus bought credits, minus everything spent, on any product. granted_credits and bought_credits say where the credits came from; remaining_credits is the only number that gates a call. The signup grant appears in entries as the row with provenance granted, beside the money rows.

This is the stage that decides whether stage 6 ever happens to you. An agent that watches X-Cost-Remaining and asks for a payment link at, say, 20% left never gets walled at all — its owner has time to pay while it is still working. Stage 6 describes the worst case because it is the hardest one, not because it is the expected one.

Stage 5 — When something goes wrong: one envelope, sixteen codes

Every error, from every host, has the same body:

{
  "type": "billing",
  "code": "payment_required",
  "message": "Balance exhausted. An owner must approve a top-up.",
  "docs_url": "https://forum.bot/llms.txt",
  "next_action": "top_up",
  "action_url": "https://console.forum.bot/",
  "request_id": "req_a1b2c3"
}

type, code and message are always present. Branch on code. Never on the HTTP status alone (five different codes are 401, and they mean very different things) and never on message (it is for humans and may be reworded).

next_action is a verb from a closed list. action_url appears on the three walls a human must clear, and is a stable address you can safely put in an email or a log — never a link that expires. request_id is on every response, error or not; quote it to support and the exact call can be found.

code HTTP what you do
invalid_key 401 terminal. Do not retry. Re-check where the credential came from
key_expired 401 rotate the key (stage 8), or escalate
key_revoked 401 terminal. Stop and escalate — someone revoked this deliberately
key_blocked 401 terminal. next_action: contact_support, address in action_url
session_expired 401 console sessions only — log in again from the console. You never hold one
scope_denied 403 the platform operator’s wall — no customer key is granted it; nothing a data call meets
verification_required 403 a human must attach a card. Stage 7
account_suspended 403 stop entirely. The owner contacts support
payment_required 402 stage 6. Ask for a link, hand it to your owner, wait
budget_cap_reached 402 reserved — nothing raises it today
rate_limited 429 wait exactly retry_after seconds, then resume
email_exists 409 that email already has an account. Never retry the signup
invalid_params 400 fix the request. Never blind-retry
not_found 404 re-read /v1/products. Do not retry unchanged
upstream_error 502 retry with backoff. You were not charged
internal_error 500 back off, keep the request_id

Two guarantees worth relying on. Only 2xx data responses are billed — an upstream_error costs you nothing, and walls carry X-Cost-Charged: 0 to prove it. And a wall is never silent: if a next step exists, the answer names it.

Stage 6 — The money wall, end to end

This is the stage that makes this platform different, so here it is in full.

  you ──▶ data call ──▶  402 payment_required
                          next_action: top_up
                          action_url:  https://console.forum.bot/

   ├──▶ POST /v1/account/topup  ──▶  201 { hosted_url: "https://checkout.stripe.com/..." }

   ├──▶ give BOTH urls to your owner ── a human pays on the provider's page
   │                                    (card details never touch the Forum)

   └──▶ poll /v1/account/usage ──▶ bought_credits > 0  (~15 s)  ──▶ resume

1. The wall.

{ "type": "billing", "code": "payment_required",
  "message": "Balance exhausted. An owner must approve a top-up.",
  "next_action": "top_up", "action_url": "https://console.forum.bot/",
  "request_id": "req_a1b2c3" }

action_url is the page to send a person to. It explains what happened in plain language, holds no state, asks for nothing, and its address never changes.

2. Ask for the payment. You may call this at any time — before the wall as well as after — and a never-verified account may call it.

curl -s -X POST https://api.forum.bot/v1/account/topup \
  -H "Authorization: Bearer $FORUM_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"amount_minor": 1000, "currency": "USD"}'
{
  "intent": {
    "intent_id": "int_dd731574dbcb38dc8f2f612c",
    "type": "topup", "state": "created",
    "hosted_url": "https://checkout.stripe.com/c/pay/cs_live_…",
    "amount_minor": 1000, "currency": "USD"
  }
}

amount_minor is cents, and the minimum is 1000 — ten US dollars. USD only. Below the minimum you get our own error, not the provider’s:

{ "type": "validation", "code": "invalid_params",
  "message": "the minimum top-up is 1000 USD minor units", "next_action": "top_up" }

3. Hand it over. hosted_url is a one-time page at the payment provider and it expires; action_url from the wall is the stable explainer. Give a human both. Do not open the payment page yourself, do not scrape it, and never ask anyone for card details — the Forum has no field anywhere that accepts a card, and anything claiming otherwise is not us.

4. Wait, then resume. Credits appear about fifteen seconds after the payment completes. Poll /v1/account/usage until balance.bought_credits rises. Do not open a second payment because the first has not landed yet — that is how an owner gets charged twice. The platform will not double-credit you, but it also cannot un-charge a card.

One credit is $0.001, so a $10 top-up is 10,000 credits — at 1 credit per One Call, 10,000 calls. /v1/products is the authority on what each product costs.

Stage 7 — Verification

Verification means a real card is attached to the account. It uses the same machinery:

curl -s -X POST https://api.forum.bot/v1/account/verify \
  -H "Authorization: Bearer $FORUM_KEY" -H "Idempotency-Key: $(uuidgen)"
{ "intent": { "intent_id": "int_...", "type": "verification", "state": "created",
              "hosted_url": "https://checkout.stripe.com/c/pay/cs_live_…" } }

There is no amount_minor: verification places a zero-amount hold and charges nothing. Hand the hosted_url to your owner exactly as in stage 6.

You do not have to verify first in order to buy credits. A successful payment of either kind moves the account from untrusted to verified — the tier and the first bought credits arrive on the same event.

Stage 8 — Keys: issue, list, rotate, revoke

# issue a key — the secret is in this answer and nowhere else, ever
curl -s -X POST https://api.forum.bot/v1/keys \
  -H "Authorization: Bearer $FORUM_KEY" -H 'Content-Type: application/json' \
  -d '{"name": "my-agent"}'      # the body is {} or {"name": "..."} — nothing else

# list — masked. Prefixes and attributes only. No secret has ever appeared here
curl -s https://api.forum.bot/v1/keys -H "Authorization: Bearer $FORUM_KEY"

# rotate — a new secret with the same attributes; the old one turns terminal
curl -s -X POST https://api.forum.bot/v1/keys/key_646877662031cbcc/rotate \
  -H "Authorization: Bearer $FORUM_KEY"

# revoke — 204, and the data plane refuses the key within seconds
curl -s -X DELETE https://api.forum.bot/v1/keys/key_646877662031cbcc \
  -H "Authorization: Bearer $FORUM_KEY"

Every key of the account is an equal credential — it carries no scopes and reaches every served product, funded from the account’s one balance — so a body naming scopes (or budget_cap_credits) is refused with invalid_params, never silently ignored.

A revoked or rotated-away key answers key_revoked — the truth about itself — not a vague invalid_key. Revocation is measured from outside in hundredths of a second and is promised within five. If you think a key leaked, revoke it and trust that number.

Stage 9 — A whole agent, in one piece

Standard library only. It covers the walls in the order you will actually meet them.

import json, time, urllib.error, urllib.request, uuid

ENGINE, GATEWAY = "https://api.forum.bot", "https://data.forum.bot"


def call(method, url, key=None, body=None, idem=False):
    """Returns (status, headers, parsed_body). Never raises on an HTTP error —
    a wall is an answer, not an exception."""
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(url, data=data, method=method)
    if key:
        req.add_header("Authorization", f"Bearer {key}")
    if data:
        req.add_header("Content-Type", "application/json")
    if idem:
        req.add_header("Idempotency-Key", f"idem-{uuid.uuid4().hex}")
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, dict(r.headers), json.loads(r.read() or b"{}")
    except urllib.error.HTTPError as e:
        return e.code, dict(e.headers), json.loads(e.read() or b"{}")


def sign_up(email):
    _s, _h, root = call("GET", ENGINE + "/")
    _s, _h, out = call("POST", ENGINE + "/v1/accounts", idem=True, body={
        "identity": {"type": "email", "email": email},
        "tos_version": root["for_agents"]["tos_version"],   # never hardcode this
    })
    return out["key"]["secret"]        # shown exactly once — persist it here


def ask_the_owner_for_money(key, dollars=10):
    """The one thing an agent cannot finish alone."""
    _s, _h, out = call("POST", ENGINE + "/v1/account/topup", key=key, idem=True,
                       body={"amount_minor": dollars * 100, "currency": "USD"})
    print("PAY HERE (one-time):", out["intent"]["hosted_url"])
    print("What this is about:  https://console.forum.bot/")
    for _ in range(120):               # ~15 s typical; be patient, never re-open
        time.sleep(5)
        _s, _h, usage = call("GET", ENGINE + "/v1/account/usage", key=key)
        if usage["balance"]["bought_credits"] > 0:
            return True
    return False


TERMINAL = {"invalid_key", "key_revoked", "key_blocked", "account_suspended", "not_found"}


def fetch(key, path):
    for _attempt in range(5):
        status, headers, body = call("GET", GATEWAY + path, key=key)
        if status == 200:
            left = headers.get("X-Cost-Remaining", "unknown")
            if left != "unknown" and int(left) < 200:
                ask_the_owner_for_money(key)      # top up EARLY — never wait for the wall
            return body

        code = body.get("code")
        if code == "rate_limited":
            time.sleep(int(body.get("retry_after", 5)))
        elif code == "upstream_error":
            time.sleep(2 ** _attempt)             # not charged; safe to retry
        elif code == "payment_required":
            if not ask_the_owner_for_money(key):
                raise RuntimeError(f"nobody paid; {body['action_url']}{body['request_id']}")
        elif code in ("verification_required", "key_expired"):
            raise RuntimeError(f"a human is needed: {code} -> {body.get('action_url', '')}")
        elif code in TERMINAL:
            raise RuntimeError(f"terminal: {code} ({body['request_id']})")   # never retry
        else:
            raise RuntimeError(f"unhandled: {code} ({body.get('request_id')})")
    raise RuntimeError("gave up after 5 attempts")


if __name__ == "__main__":
    k = sign_up(f"agent-{uuid.uuid4().hex[:8]}@example.com")
    print(fetch(k, "/onecall/current?lat=51.5074&lon=-0.1278")["data"][0]["temp"])

Stage 10 — The rules you must not break

  • Never put a credential in a URL. Keys are opaque bearer tokens in the Authorization header. A key in a query string ends up in logs, referrers and caches.
  • Never retry a terminal code. invalid_key, key_revoked, key_blocked, account_suspended mean stop. Retrying them is how an account gets flagged for abuse.
  • Never blind-retry a 400. Fix the call.
  • Always send an Idempotency-Key on signup and on the payment routes. It is the only thing that makes a dropped connection recoverable.
  • Never ask a person for card details, and never accept them yourself. Payment happens on the provider’s own page. There is no Forum surface anywhere that takes a card.
  • Attribute the data when meta.licence.attribution_required is true. The text to use is in the same response.
  • Keep the request_id of anything that went wrong. It is how the exact call is found.

Terms of service: https://forum.bot/tos/last.json · Prices: https://api.forum.bot/v1/products · Machine quickstart: https://forum.bot/llms.txt · Full API: https://api.forum.bot/openapi.yaml · Something wrong, no account needed: mailto:info@forum.bot