Computaco

Start

The hosted API#

ComputerWorld runs as a hosted service at https://computaco.ai/v1. You sign up, make an API key, and drive worlds over HTTP: boot one from a built-in world or your own definition, grant an agent an environment, send it pointer and keyboard actions, read its screen as a scene or a PNG, snapshot and fork it, and run and verify expert procedures and societies. Every world is the same deterministic engine as the Python wheel and the npm package, so a run over HTTP replays to the same state hashes.

The contract is cloud/openapi.yaml (also served at https://computaco.ai/openapi.yaml); this page is the guide to it. The web console at /console/ is built on the same routes.

Quickstart#

Sign up (the answer carries your first API key, shown once), then boot a world and grant an environment:

API=https://computaco.ai/v1
curl -s $API/auth/signup -H 'content-type: application/json' \
  -d '{"username":"ada","password":"a long enough password"}'
# → {"user": {...}, "api_key": {"id": "key_…", "key": "cw_live_…", …}}
export CW_KEY=cw_live_…

curl -s $API/worlds -H "authorization: Bearer $CW_KEY" \
  -d '{"world":"agent-desktop","seed":1}'
# → {"id": "w_…", "state_hash": "…", "tick": 0, "machines": ["workstation"], …}
W=w_…

curl -s $API/worlds/$W/environments -H "authorization: Bearer $CW_KEY" -d '{}'
# → {"id": "env_1", "token": "cwa_w_…", "config": {"actor": "ada", "machines": ["workstation"],
#     "actions": ["pointer.v1", "keyboard.v1", "application.v1"], …}}

Hand the actor token to the agent. It can read the screen, act, and look:

T=cwa_w_…
curl -s "$API/worlds/$W/environments/env_1/scene?elements=1" -H "authorization: Bearer $T" \
  | jq '.elements[] | select(.interaction == "shell:launch:editor") | .center'
# → [34, 187]
curl -s $API/worlds/$W/environments/env_1/step -H "authorization: Bearer $T" -d '{"actions": [
  {"family": "pointer.v1", "op": "click", "payload": {"x": 34, "y": 187, "width": 1280, "height": 800}},
  {"family": "keyboard.v1", "op": "type", "payload": {"text": "Hello"}}]}'
curl -s -o screen.png "$API/worlds/$W/environments/env_1/render?width=1280&height=800" \
  -H "authorization: Bearer $T"

The Python and JavaScript clients wrap all of this, with element finding, retries and errors that carry the API's code:

from computerworld_cloud import Client   # pip install "computerworld-cloud @ git+https://github.com/ComputaCo/computerworld.git#subdirectory=python"

with Client("cw_live_…").create_world("agent-desktop", seed=1) as world:
    env = world.environment()
    env.click(interaction="shell:launch:editor")
    env.render().save("screen.png")

The same calls by hand:

Python#

import requests

API = "https://computaco.ai/v1"
owner = requests.Session()
owner.headers["authorization"] = "Bearer cw_live_…"

world = owner.post(f"{API}/worlds", json={"world": "agent-desktop", "seed": 1}).json()
env = owner.post(f"{API}/worlds/{world['id']}/environments", json={}).json()

agent = requests.Session()
agent.headers["authorization"] = f"Bearer {env['token']}"
base = f"{API}/worlds/{world['id']}/environments/{env['id']}"
scene = agent.get(f"{base}/scene", params={"elements": 1}).json()
x, y = next(e["center"] for e in scene["elements"] if e["interaction"] == "shell:launch:editor")
result = agent.post(f"{base}/step", json={"actions": [
    {"family": "pointer.v1", "op": "click", "payload": {"x": x, "y": y, "width": 1280, "height": 800}},
]}).json()
print(result["outcomes"][0]["success"], result["state_hash"])
open("screen.png", "wb").write(agent.get(f"{base}/render").content)

# The owner forks, and runs an expert procedure on the fork.
fork = owner.post(f"{API}/worlds/{world['id']}/fork", json={}).json()
run = owner.post(f"{API}/worlds/{fork['id']}/procedures/run",
                 json={"ref": {"world": "agent-desktop", "id": "legal-engagement"}, "seed": 2}).json()
print(run["verification"]["passed"], run["demonstration"]["step_count"])

Send a User-Agent of your own. Cloudflare, in front of the API, refuses Python's bare urllib default (Python-urllib/3.x) with a 403 whose body is error code: 1010, before the request reaches ComputerWorld; requests, httpx, curl and browsers are not affected. With urllib, set one: urllib.request.Request(url, headers={"User-Agent": "my-agent/1.0", ...}).

JavaScript#

const API = 'https://computaco.ai/v1';
const owner = (path, body) => fetch(API + path, {
  method: body ? 'POST' : 'GET',
  headers: { authorization: `Bearer ${process.env.CW_KEY}`, 'content-type': 'application/json' },
  body: body && JSON.stringify(body),
}).then((r) => r.json());

const world = await owner('/worlds', { world: 'agent-desktop', seed: 1 });
const env = await owner(`/worlds/${world.id}/environments`, {});
const step = await fetch(`${API}/worlds/${world.id}/environments/${env.id}/step`, {
  method: 'POST',
  headers: { authorization: `Bearer ${env.token}`, 'content-type': 'application/json' },
  body: JSON.stringify({ actions: [{ family: 'keyboard.v1', op: 'key', payload: { key: 'Ctrl+Alt+t' } }] }),
}).then((r) => r.json());
console.log(step.state_hash);

Authentication#

CredentialHow it is sentWhat it can do
Sessionthe cw_session cookie that sign-up and sign-in set (HttpOnly, SameSite=Lax, Secure over HTTPS, 30 days)Everything an API key can, from the console's origin. A write sent with a cookie must carry an Origin of the same host.
API keyAuthorization: Bearer cw_live_…Owns the account's worlds: every route.
Actor tokenAuthorization: Bearer cwa_<world>_…Only GET …/environments/{env}, …/step, …/observe, …/scene and …/render of the one environment it was issued for.
Agent tokenAuthorization: Bearer cwt_…Only POST /v1/agent/chat, POST /v1/agent/vision and GET /v1/agent/usage: what the Computatrum app is given by the device sign-in (§ Computatrum).
  • Accounts are a username (3–32 of a-z 0-9 _ -) and a password (10–200 characters), stored as PBKDF2-HMAC-SHA-256 with a 16-byte random salt and 100,000 iterations, the most workerd's WebCrypto allows. Unknown usernames are hashed too, so timing does not say which exist. Five wrong passwords for one username lock it for the rest of a 15-minute window, and each address may try sign-up and sign-in ten times a minute.
  • API keys are shown once, when made; the service keeps their SHA-256 and a 12-character prefix. GET /v1/keys lists them, POST /v1/keys makes one, DELETE /v1/keys/{id} revokes it at once.
  • Actor tokens are the HTTP form of the engine's owner/actor split: the API key holds the World, the token holds one Environment. A token is stored as its hash inside its world's object; revoking the environment, resetting the world or deleting it ends it, and a fork issues new tokens for its copies of the environments.

Endpoints#

All paths are under /v1. Bodies and answers are JSON unless noted. Every answer that changes a world carries state_hash and tick (the world clock, logical microseconds).

Accounts

Method and pathWhat it does
POST /auth/signup {username, password, referral_code?}Make an account; sets the session and returns the first API key. A referral code is kept on the account and applied when it upgrades; one that is unknown, inactive, expired or used up is a 400 invalid_code.
POST /auth/signin {username, password}Sets the session.
POST /auth/signoutEnds the session.
GET /meThe account, its plan's quotas, its usage this minute, today and in total, and billing: plan, status (none, active, past_due, canceled), renewal, discount, the sign-up referral code (§ Plans and billing).
PATCH /me {name?, email?, date_of_birth?}Set or clear the account's profile; a field left out is kept, null clears it. A name is at most 100 characters, an email needs an @ and a dotted domain, a date of birth is a real past date written yyyy-mm-dd (1900 or later). Anything else is a 400.
GET /me/usage?days=30Daily requests, steps, renders, worlds created and model calls.
POST /me/password {current, next}Change the password; other sessions end.
DELETE /me {password} or {confirm}Delete the account and everything it owns — its swarms and their arms, its worlds with their snapshots and actor tokens, its blueprints, keys, sessions and usage — at once and for good; the username is free again. From a session the body carries the current password; with an API key, confirm (the account's username) or the password. A missing or wrong confirm is a 400, a wrong password a 401.
GET /keys, POST /keys {name}, DELETE /keys/{id}API keys.

Plans and billing — see § Plans and billing below.

Method and pathWhat it does
GET /plansPublic: Free, Pro and Enterprise with their prices and quotas, and billing_configured.
POST /billing/checkout {plan: "pro", referral_code?}A Stripe Checkout URL for the Pro subscription, with the code's discount.
POST /billing/portalA Stripe Customer Portal URL: card, invoices, cancel.
POST /billing/webhookStripe's signed events (set in Stripe's dashboard).

Catalog (no credential needed)

Method and pathWhat it does
GET /healthLiveness and the engine build (engine.build, version, wasm_sha256).
GET /catalog/worlds, GET /catalog/worlds/{name}The built-in worlds (each with boots: whether this deployment's engine boots it, and the engine's reason if not), and one's whole definition.
GET /catalog/procedures[?world=], GET /catalog/procedures/{world}/{id}Expert procedures.
GET /catalog/societies, GET /catalog/societies/{world}/{id}Society specs.
GET /catalog/vocabularyPlatforms (and platforms_available, what this engine draws), presentations, zones, service kinds, action families, observation channels.

Blueprints and topology — an editable world definition kept by the account.

Method and pathWhat it does
GET /blueprints, POST /blueprints {name, from | definition}List; make one from a built-in world, another blueprint, or a definition.
GET, PUT {name?, definition?}, DELETE /blueprints/{id}One blueprint.
GET /blueprints/{id}/topologyIts machines (profile, user, address, node, theme, presentation as a word, installed_apps, desktop_apps, packages, the initial_files paths), nodes, links (with id, their index), services, DNS and themes.
PATCH /blueprints/{id}/topology {edits: [...]}Apply edits in order, validate, save — all or nothing; a failure names the edit.
POST /validate {definition}Boot it with the engine and say whether it is a world.

The edits: add_machine {computer, node?, links?, theme?}, update_machine {id, set}, remove_machine {id} (with its node's links when nothing else is on the node), add_node, remove_node, add_link {link: {from, to, bidirectional?, latency_us?, loss_per_million?}}, update_link {id, set}, remove_link {id}, add_service {service}, update_service {id, set}, remove_service {id}, set_theme {machine, theme}, set_presentation {machine, presentation}, set_applications {machine, applications}, set_internet, set_implicit_lan, set_gateway {gateway}, set_metadata {key, value}. The fields are the world schema's.

Worlds (owner)

Method and pathWhat it does
GET /worlds, POST /worlds {world | blueprint | definition, seed?, platform?, name?, procedure?, host?}List; boot one. procedure ({world, id} or inline) boots it for that procedure: its machines get the desktops the procedure names and the world goes to a host carrying its applications (§ Hosts).
GET, DELETE /worlds/{id}Summary (with boot_ms and memory.wasm_bytes); delete with its snapshots and tokens.
GET /worlds/{id}/state_hash{state_hash, tick}.
POST /worlds/{id}/advance {ticks | duration}Move the clock ("30s", "2h", "1d"): timers, settlement windows and queues fire.
POST /worlds/{id}/reset {seed?}Back to the definition; environments are dropped.
GET /worlds/{id}/inspect[?pointer=]The owner's view of the whole state, or one JSON Pointer into it.
GET /worlds/{id}/definition, GET /worlds/{id}/trajectoryThe definition; every action so far.
GET, PATCH /worlds/{id}/services/{service}/stateA service's state; an owner write (merge patch), recorded for replay.
POST /worlds/{id}/perform {environment, gesture}One grounded gesture ({do: "click", target: {label: "Post"}}), as procedures use.
GET /worlds/{id}/topologyThe running world's topology (live: true).
POST /worlds/{id}/machines {computer, node?, links?}, DELETE /worlds/{id}/machines/{m}Add or remove a machine live. Links, services and themes of a running world are fixed at boot: edit a blueprint and boot it.

Snapshots, forks, export

Method and pathWhat it does
GET, POST /worlds/{id}/snapshots {label?}Kept snapshots.
POST /worlds/{id}/snapshots/{s}/restore, DELETE /worlds/{id}/snapshots/{s}Restore (environments granted later are dropped), drop.
POST /worlds/{id}/fork {snapshot?, name?, host?}A new world from now or a snapshot, at the same state hash, with the environments carried over under new tokens; host moves the copy to another host.
GET /worlds/{id}/export, POST /worlds/import {snapshot}A portable bundle (definition, seed, the engine's snapshot, environments) and back.

Environments

Method and pathWhoWhat it does
GET, POST /worlds/{id}/environments {actor?, machines?, actions?, observations?, action_budget?, label?}ownerGrants; a new one returns its token once. Defaults: the first machine and its user, pointer.v1, keyboard.v1, application.v1, semantic.v1, a budget of 100,000 actions.
GET, DELETE /worlds/{id}/environments/{env}owner (GET: actor too)One grant; revoke it.
POST …/environments/{env}/step {actions: [{family, op, machine?, payload}]}bothUp to 64 actions. machine may be left out when the grant has one. An action outside the grant is an unsuccessful outcome, not an HTTP error.
GET …/environments/{env}/observebothThe granted observation channels.
GET …/environments/{env}/scene?width&height&elements=1bothThe scene graph; elements adds every interactive node with its role, label, interaction id, clipped rect and center, front first.
GET …/environments/{env}/render?width&height&format=png|rgbabothThe screen of the environment's focused machine; headers X-State-Hash, X-Tick, ETag. Send If-None-Match to poll: an unchanged screen is a 304, not drawn and not counted.

Action families and payloads are the engine's.

Procedures (expert procedures)

Method and pathWhat it does
POST /procedures/instantiate {procedure | ref: {world, id}, seed, overrides?}Draw an instance.
POST /worlds/{id}/procedures/stage {instance}Write its setup files.
POST /worlds/{id}/procedures/project {instance, environment, options?}Perform it through an environment (or {role: env}) and return the demonstration.
POST /worlds/{id}/procedures/verify {instance, captures?}Its checks against the world; captures (a demonstration's) fills checks that name a captured {{@name}}.
POST /worlds/{id}/procedures/run {procedure | ref, seed, fork?, include_steps?, options?}All four; grants its own sessions. fork: true runs in a throwaway copy and leaves the world as it was. The world must be on a host carrying the applications the procedure launches and have its desktops: a 409 says which host, or to boot a world with procedure.

Swarms (societies) — see § Swarms below.

Method and pathWhat it does
POST /swarms/compile {spec | ref, world?, seed?}The compiled plan: people, devices, accounts, follows, arms, what was left uncompiled.
GET /swarms, POST /swarms {spec | ref, world?, seed?, name?, journal?}List; compile and boot (round 0). journal is "builtin" (the journal recorded beside a built-in society), a journal file's text, or [{key, reply}].
GET, DELETE /swarms/{id}State: round, arms with their worlds, hashes and metrics; delete with the arms' worlds.
POST /swarms/{id}/rounds {count?, ask_model?}Run rounds in every arm; the matched fork happens at fork_at.
POST /swarms/{id}/forkFork the arms now.
GET, POST /swarms/{id}/journalThe LLM journal; answers for pending decisions.
GET /swarms/{id}/trace?arm=&since=, GET /swarms/{id}/summaryOne arm's trace (JSON lines); outcomes, checks, divergence and the CRN check.

Swarms#

A swarm is a society run over the API. The spec is compiled in the Worker by cloud/src/society/, a TypeScript port of scripts/society/ that gives the same compiled people and follows and the same state hashes as the Python runner on the same engine, and replays the recorded rumor-cascade journal with no miss. A swarm boots a prefix arm; at fork_at each arm of the design becomes a world of its own (the prefix exported, imported, the runner's state copied, the arm's ops applied), and every round runs in every arm together. Arms count against the world quota.

People whose policy is llm ask a model. Their replies are the journal, keyed by the prompt's bytes, so a run with a journal replays exactly. A round that meets a prompt the journal lacks is rolled back in that arm and comes back as pending (the key, the person and the prompt); answer them with POST /swarms/{id}/journal and run the round again, or send ask_model: true and the deployment asks its model (DeepSeek, deepseek-flash) and reruns the round itself, counting each call against model_calls_per_day.

curl -s $API/swarms -H "authorization: Bearer $CW_KEY" \
  -d '{"ref": {"world": "company-2026", "id": "rumor-cascade"}, "journal": "builtin"}'
curl -s $API/swarms/$S/rounds -H "authorization: Bearer $CW_KEY" -d '{"count": 2}'
curl -s $API/swarms/$S/summary -H "authorization: Bearer $CW_KEY"

Errors and limits#

An error is {"error": {"code", "message"}}: 400 bad_request, 401 unauthenticated, 403 forbidden, 404 not_found (also another account's world), 409 conflict, 413 too_large, 422 invalid_world / invalid_edit / invalid_procedure, 429 rate_limited or quota_exceeded with Retry-After, 500 engine_error, 502 model_error, 503 model_unavailable (with Retry-After), agent_not_configured or billing_not_configured, 507 world_too_large (see § Limits of one object). The device sign-in's polling answers 400 with the OAuth device-grant codes (§ Computatrum).

Each account's quotas are its plan's (GET /v1/me shows them, GET /v1/plans lists every plan's); see § Plans and billing.

An actor token's work counts against its world's owner; it is limited in its world's object at Free's per-minute rates (the object does not know its owner's plan) and added to the account's counts after it is answered.

Plans and billing#

QuotaFreePro, $29 a monthEnterprise
Worlds running at once (swarm arms count)832128
Environments per world1664256
Kept snapshots per world1664256
Blueprints32128512
Swarms, and people in each2, of 328, of 12832, of 512
API keys1025100
Definition size4 MiB4 MiB4 MiB
Requests a minute6003,00012,000
Actions (step) a minute1,2006,00024,000
Renders a minute1206002,400
Model calls a day (swarms' LLM people)5005,00050,000
Computatrum agent-minutes in a rolling 5 hours151201,200
Computatrum agent-minutes in a rolling week303003,000

Free is the deployment's QUOTA_* vars (cloud/wrangler.jsonc); Pro and Enterprise are in cloud/src/quotas.ts and never fall below Free. The definition size is the same on every plan because a world is one Durable Object, whose isolate has 128 MB on every plan (§ Limits of one object): there is no larger memory class to sell. Enterprise is by agreement ([email protected]) and set by the operator, not sold here.

Each limit is enforced from the account's plan where it was before: requests, steps and renders a minute when a request is authorized; API keys, blueprints, swarms and model calls in the objects that hold them; a world's environments and snapshots in the Worker before it forwards the grant or snapshot (the world object itself holds up to the largest plan's). What an account already holds when it drops to a smaller plan is kept; it cannot make more until it is under the limit.

Upgrading. POST /v1/billing/checkout {"plan": "pro", "referral_code": "FIRST100"} answers a Stripe Checkout URL (a subscription, monthly, for the price STRIPE_PRICE_PRO, or the price with lookup key computaco_pro_monthly, which it makes on first use). The account stays Free until Stripe tells the webhook it paid. Success and cancel return to /console/#/settings?billing=success and …=cancel. POST /v1/billing/portal answers a Customer Portal URL to change the card, see invoices or cancel (at the period's end).

The webhook (POST /v1/billing/webhook) checks Stripe-Signature — HMAC-SHA256 of <t>.<payload> under STRIPE_WEBHOOK_SECRET, t within 300 s — and handles each event id once:

EventEffect
checkout.session.completedPro, active; the renewal date from the subscription; the code redeemed.
customer.subscription.updatedactive/trialing → Pro; past_due → past due; unpaid, canceled, incomplete_expired → Free. Renewal and cancel-at-period-end follow.
customer.subscription.deletedFree, canceled.
invoice.payment_failedpast_due: Pro is kept for 7 days (BILLING_GRACE_DAYS), then the account is Free until a payment succeeds.
checkout.session.expiredThe code's reserved place is given back.

Without STRIPE_SECRET_KEY (and, for the webhook, STRIPE_WEBHOOK_SECRET) every billing route answers 503 billing_not_configured, GET /v1/plans says billing_configured: false, and the pricing page and console say upgrades open soon. No account is shown as paid that Stripe has not charged.

Referral and discount codes. A code has a percentage off, the plan it is for, a duration (forever — for the life of the subscription —, once, or repeating for duration_in_months), an optional max_redemptions and expires_at, and an active flag. An account names one at sign-up (referral_code, kept on the account) or at checkout. Checkout reserves one of the code's places for the session's hour, in the one accounts object without awaiting between the check and the write, so the place after the last is refused (the tests run 101 checkouts at once against 100 places: 100 sessions, one invalid_code); payment redeems it, an expired session gives it back. An account redeems a code once. The discount is a Stripe coupon cw_<CODE> made from the code on its first checkout. The launch code is FIRST100: 20% off Pro, forever, for the first 100 customers.

The operator's code routes sit beside the account routes (§ Deploying), behind ADMIN_TOKEN:

curl -s $API/admin/codes -H "authorization: Bearer $ADMIN_TOKEN" -d '{"code": "FIRST100",
  "percent_off": 20, "plan": "pro", "duration": "forever", "max_redemptions": 100}'   # 201
curl -s $API/admin/codes -H "authorization: Bearer $ADMIN_TOKEN"                    # {codes: [...]}, with remaining
curl -s $API/admin/codes/FIRST100 -H "authorization: Bearer $ADMIN_TOKEN"           # the code and each account's reservation or redemption
curl -s -X POST $API/admin/codes/FIRST100/deactivate -H "authorization: Bearer $ADMIN_TOKEN"
curl -s -X PATCH $API/admin/users/ada -H "authorization: Bearer $ADMIN_TOKEN" -d '{"plan": "enterprise"}'

Computatrum#

Computatrum is Computaco's desktop app that works the person's own computer, with their cursor and keyboard, as a computer-use agent. Each step it takes asks two models: a vision model what is on the screen and where, and a chat model what to do next. This API serves both, signs the app in, and meters every call against the account's plan (in agent-minutes), or pay as you go (in credits).

RouteCredentialWhat it does
POST /v1/agent/chatany (agent token included)OpenAI-style chat completions by DeepSeek deepseek-flash (AGENT_CHAT_MODEL)
POST /v1/agent/visionany{image, prompt, max_tokens?} → {text, usage} by Workers AI @cf/zai-org/glm-5.3-flash (AGENT_VISION_MODEL)
GET /v1/agent/usageanyboth windows (minutes), pay as you go this month (credits), the last 30 days by model
POST /v1/agent/devicenonestarts a device sign-in
POST /v1/agent/device/tokennonethe app polls it for its agent token
GET /v1/agent/device/{user_code}, POST …/approve, POST …/denyconsole session onlythe person approves or declines the app
GET /v1/agent/tokens, DELETE /v1/agent/tokens/{id}session or API keythe signed-in apps, and signing one out
GET, PUT /v1/billing/paygsession or API keypay-as-you-go settings

Chat. The body is OpenAI's: messages (1–500 of {role: system|user|assistant|tool, content}), max_tokens (default 1,024, at most 8,192; more is lowered to 8,192), temperature, top_p, stop, response_format, tools, tool_choice and reasoning_effort are passed on; model and anything else are ignored, and stream: true is refused (the answer comes whole). The answer is OpenAI's shape — {id, object: "chat.completion", created, model, choices: [{index, message, finish_reason}], usage: {prompt_tokens, completion_tokens, total_tokens, cached_tokens}} — plus metering: {cost_micro, credits, billed: "plan"|"payg", ms, minutes, task?, usd?}, what the call was charged. A request the model refuses (400, e.g. too long) answers 400 and costs nothing; the model unreachable or failing answers 503 model_unavailable with Retry-After: 5.

Vision. image is a base64 PNG, JPEG or WebP (a data: URL works too) of at most 4 MiB decoded; prompt is the question, up to 20,000 characters; max_tokens defaults to 512, at most 2,048. The answer is {text, model, usage: {prompt_tokens, completion_tokens, total_tokens, images: 1}, model_ms, metering}. The model is asked with low reasoning effort and temperature 0.1. Ask it for coordinates in the image's pixels and it gives them (below).

Signing the app in. The device flow (RFC 8628's shape, our error envelope):

curl -s -X POST $API/agent/device -d '{"client_name": "Computatrum on ada-mbp"}'
# → {"device_code": "cwd_…", "user_code": "BCDF-GHJK", "verification_url": "https://computaco.ai/console/#/device?code=BCDF-GHJK",
#    "verification_uri": "https://computaco.ai/console/#/device", "expires_in": 900, "interval": 5, "client_name": "…"}
curl -s -X POST $API/agent/device/token -d '{"device_code": "cwd_…"}'
# → 400 {"error": {"code": "authorization_pending"}} every 5 s until the person approves, then once:
# → {"access_token": "cwt_…", "token_type": "bearer", "scope": "agent", "token_id": "agt_…", "user": {"id", "username", "plan"}}

The app opens verification_url; the console (signed in) shows the app's name and the code, and the person approves or declines it. Polling faster than interval answers slow_down; a declined sign-in answers access_denied, an expired one (15 minutes) expired_token, a used or unknown code invalid_grant. A user code is 8 letters from 20 consonants (34 bits), lives 15 minutes, and is approved only from a console session, never with a key; a device code is 32 random bytes, stored as its SHA-256 and exchanged once. The agent token is 32 random bytes, stored as its SHA-256, does not expire, and reaches only the three routes above; the console's Usage page lists the signed-in apps and signs one out (DELETE /v1/agent/tokens/{id}) at once. Starting a sign-in counts against the address's ten sign-up and sign-in attempts a minute.

Agent-minutes, credits and the two windows#

A person thinks in time, not tokens, so a plan's windows are counted in agent-minutes: the wall-clock time Computatrum is working on a task. A developer thinks in cost, so pay as you go is counted in credits: $0.001 of model cost. Both are in cloud/src/meter.ts.

A task's minutes. The app sends the task each call belongs to, a UUID per task, in the header X-Computatrum-Task (or task_id in the body; 1–64 letters, digits, -, _). A call adds its own duration (from when the Worker receives it to when the model has answered) plus the gap since the end of the same task's previous call, up to 15 seconds (TASK_GAP_CAP_MS); a call overlapping the previous one adds only the time past it. The first call of a task, and a call with no task, adds its own duration only. So time the agent spends waiting for the person — a confirmation, a credential handed over, a pause because they moved the mouse — is counted for at most 15 s, however long it lasts.

Why 15 seconds, measured from the event logs of 12 real tasks run by the app's end-to-end tests against the live models on dev (the four of 2026-10-28 in target/computatrum-evidence/ev/*-real/, and eight more on dev with this metering, two rounds of the same four tests): the gap between one step's decision and the next step's screen read is the agent acting, the screen settling and the next capture, and the 39 such gaps were 0.43–2.22 s (median 0.92 s). The longest the agent can make one on its own is 12.7 s: a wait of at most 10 s (MAX_WAIT_MS), the 2 s settle cap and about 0.7 s to capture and show the marker. 15 s is above that, and well below a person reading a card and answering.

What a minute costs, measured (the same eight tasks on dev, 2026-10-28; metering per call in each read and think event):

TaskStepsWall timeChargedCreditsCredits a minute
Loop: order form, one purchase confirmation675.8 s / 55.0 s74.9 s / 54.5 s3.700 / 3.8432.96 / 4.23
Loop: login, the password handed to the person4 / 524.7 s / 27.0 s24.0 s / 26.4 s2.278 / 3.0075.70 / 6.83
App: sign in, pause for the mouse, Allow once, done655.6 s / 56.5 s55.2 s / 56.0 s3.826 / 3.7904.16 / 4.06
App: the stop key mid-task17.3 s / 6.0 s6.8 s / 4.7 s0.636 / 0.5095.64 / 6.47
All eight5.04 min21.5894.28

A screen read was 0.296–0.335 credits (0.315 on average), a decision 0.173–0.505 (0.311); a step took 7.77 s at the median. The account's own GET /v1/agent/usage said 2.68 minutes after the first round, the sum of its calls' ms.

The two windows. A plan has two rolling windows, as consumer AI subscriptions do: a 5-hour window that starts with the first call after the last one ended, and a weekly window likewise. A call is admitted while both have minutes left and its whole time is counted after it runs, so a window can end up to one call over. When one is used up, calls answer 429 quota_exceeded with Retry-After the seconds to its reset (the later reset when both are) — or go to pay as you go when it is on. The windows were in credits until 2026-10-28; on the change every account's windows started again empty.

The fair-use guardrail. Minutes alone would let a loop that calls the models far faster, or with far larger prompts, than the app does spend without bound inside its minutes. So a window is also full once its calls have cost 15 credits for each minute of its limit (FAIR_USE_CREDITS_PER_MINUTE): 1,800 credits in Pro's 5 hours, 4,500 in its week. The person sees minutes, not this number; a window full this way says "fair_use": true. The app's own tasks cost 4.28 credits a minute (6.83 at most, above); the most its loop can cost is a busy desktop with every prompt at its largest — a 4,000-token decision prompt, 670 tokens of it from cache, and 300 out (1,363 µ$), a 1440×900 screen read with 200 out (368 µ$), 1.73 credits a step — at the median 7.77 s a step: 13.4 credits a minute. 15 is above that and 3.5 times the measured average, so no one using the app reaches it.

GET /v1/agent/usage:

{"plan": "pro", "unit": {"name": "minute", "task_gap_cap_s": 15, "payg": {"name": "credit", "model_cost_usd": 0.001, "price_usd": 0.0025}},
 "windows": {"five_hour": {"limit": 120, "used": 31.25, "remaining": 88.75, "started_at": 1793232000000, "resets_at": 1793250000000, "full": false},
             "weekly":    {"limit": 300, "used": 96.5, "remaining": 203.5, "started_at": 1793001600000, "resets_at": 1793606400000, "full": false}},
 "payg": {"enabled": false, "requested": false, "subscribed": false, "status": "none", "mode": "overflow", "monthly_cap_usd": 20,
          "period_start": 1790812800000, "period_end": 1793491200000, "credits": 0, "spend_usd": 0, "unreported_credits": 0},
 "last_30_days": [{"kind": "chat", "model": "deepseek-flash", "calls": 1290, "tokens_in": 1270000, "tokens_cached": 503000, "tokens_out": 361000, "images": 0, "minutes": 31.4, "credits": 401.2, "payg_credits": 0}, …]}

A call's own metering says what it added: {"cost_micro": 311, "credits": 0.311, "billed": "plan", "ms": 2140, "minutes": 0.04, "task": "4f1c2a9e-…"}.

The model costs behind a credit are the providers' list prices, always at the peak rate:

ModelInputCached inputOutput
deepseek-flash (DeepSeek, peak; off-peak is half)$0.30 / M$0.006 / M$1.20 / M
@cf/zai-org/glm-5.3-flash (Workers AI; an image is input tokens)$0.15 / M—$0.50 / M

The plans, and the most each can cost: every weekly window of a month used to the last minute, every minute at the fair-use ceiling (52/12 weeks a month):

Plan5-hour windowWeekly windowMost model cost a monthAt the measured 4.28 credits a minute
Free15 min30 min30 × 15 × $0.001 × 52/12 = $1.95$0.56
Pro, $29120 min300 min300 × 15 × $0.001 × 52/12 = $19.50$5.56
Enterprise1,200 min3,000 min$195.00by agreement

Why Pro stays profitable. Pro is $29 a month; Stripe keeps 2.9% + $0.30 = $1.14, leaving $27.86. The most a Pro account can cost in models is its weekly window used to the last minute, every week, at the fair-use ceiling: 300 min × 15 credits × $0.001 × 52/12 = $19.50 a month, leaving $8.36 (29%) at the worst possible use, before the off-peak half price DeepSeek gives most hours. At the cost real tasks measured, a fully used Pro costs $5.56 a month and leaves $22.30 (77%). The 5-hour window holds 120 of the week's 300 minutes, so one sitting can use at most two fifths of the week. Free's worst case is $1.95 a month and its measured case $0.56. 120 minutes is about a hundred tasks like the order form (55–76 s each).

Pay as you go#

For developers building their own Computatrum (or anyone past their windows): unchanged, in credits, not minutes — credits billed monthly in arrears through Stripe at $0.0025 a credit, $2.50 per 1,000 — 2.5 times model cost, which pays Stripe's fees and the free plan. It is on the account's own Stripe customer, beside any plan.

  • PUT /v1/billing/payg {"enabled": true, "monthly_cap_usd": 20, "mode": "overflow"} turns it on. The first time, it answers {"payg": …, "checkout_url": "https://checkout.stripe.com/…"}: a Checkout Session for a subscription to the metered price (STRIPE_PRICE_PAYG, or lookup key computatrum_payg_credits), which takes the card; pay as you go is on once Stripe's webhook says the subscription is (payg.subscribed). Turning it off, or changing the cap or mode, is immediate; the subscription stays (nothing is billed without use) and the Customer Portal cancels it.
  • mode: "overflow" (the default) uses the plan's windows first and pay as you go after; "always" bills every call to pay as you go and leaves the windows alone (for an app of your own with your own users).
  • monthly_cap_usd (1–10,000, default 20) is the most it spends in a calendar month (UTC), at its price; past it, calls answer 429 until the month ends or the cap is raised.
  • Each call billed this way is reported to a Stripe Billing Meter (event computatrum_credits, payload.stripe_customer_id, payload.value in whole credits) by the accounts object's alarm, every AGENT_METER_FLUSH_S seconds (60): whole credits only, the fraction kept for the next report, each report with an identifier (cw_<user>_<n>) that Stripe deduplicates, so a retry is billed once.
  • Its subscription's events carry metadata.kind = "payg" and never change the plan: created or updated active turns it on, past_due (or a failed PAYG invoice) suspends it until paid, deleted turns it off.

The Stripe objects are made by node cloud/scripts/stripe-agent-setup.mjs <tier>, which finds or makes the meter, a product and the price on it, and writes STRIPE_PRICE_PAYG and STRIPE_METER_PAYG into the tier's env file:

TierMeterPriceProduct
local, dev (test mode)mtr_test_61Vf7N6HWXMyp4NOQ412LVcDG4iLF0lMprice_1UVjPk2LVcDG4iLFrdU9lG4Pprod_VWmz6vg8SGHneG
prod (live mode)mtr_61Vf7N9Oubm4Tu21S412LVcDG4iLFH2Oprice_1UVjPn2LVcDG4iLFecAI84kTprod_VWmz0X1glSo6BJ

The models, and why#

The chat model is DeepSeek's deepseek-flash (served by DeepSeek-V4.1-Flash), the cheapest capable model the account already holds a key for. The vision model was chosen from the Workers AI catalog (wrangler ai models list, 2026-10-28) on the three screenshots above, each asked two things: to read the screen (events on a day, an email's subject and sender, a pull request's number, title and the reviewer who asked for changes) and where a control is, in pixels:

ModelRead correctlyControl found within 10 pxLatencyCost of a 960×600 screenshot
@cf/zai-org/glm-5.3-flash3 of 3, every run3 of 3, every run (2–7 px)1.3–2.9 s146 µ$
@cf/google/gemma-4-26b-a4b-it (thinking off)2 of 30 of 31.2–1.8 s~45 µ$
@cf/meta/llama-4-scout-17b-16e-instruct3 of 30 of 32.2–9.0 s~350 µ$
@cf/mistralai/mistral-small-3.1-24b-instruct3 of 3 (one name misread)0 of 31.7–2.0 s~330 µ$
@cf/moondream/moondream3.1-9B-A2B— answered empty through the REST API———

@cf/cloudflare/clef-omni, Cloudflare's own multimodal decision model, answers typed questions (yes/no, choice, score) with probabilities rather than text, so it cannot be the text answer this route promises; it could score a screen ("is the dialog open?") in a later route. GLM-5.3-flash is the only model that both read and pointed right, at a cost a step can carry; AGENT_VISION_MODEL changes it without a deploy of code (a model with no listed rate is charged at a fallback of $1 / $4 per million tokens).

Downloads#

The app's installers are 100+ MB, over the 25 MiB a Pages file may be, so they live in R2 (bucket computaco-downloads; computaco-downloads-dev for the dev tier) and the API Worker serves them, public and unmetered, through the Pages Function at /download/*:

curl -s https://computaco.ai/download/computatrum/latest.json
curl -LO https://computaco.ai/download/computatrum/computatrum-0.1.0-linux-arm64.AppImage

latest.json is {version, channel, published, files: [{platform, arch, kind, name, bytes, sha256, url}]} (platform is linux, mac or windows; kind is AppImage, deb, tar.gz, dmg, zip or exe), cached for 60 s; an installer is cached for an hour. Both answer HEAD, one Range (206, or 416 past the end), If-Range and If-None-Match. .github/workflows/computatrum.yml builds and uploads them on a computatrum-v* tag (computatrum/scripts/publish-downloads.mjs writes the manifest and uploads it last).

How it is built#

computaco.ai ───────────── /, /docs/, /console/  static files (Pages; its pages.dev names 308 to computaco.ai)
                        └─ /v1/*  Pages Function ──service binding──▶ Worker `computerworld`
                                                                       ├─ Accounts (one object): users, sessions, keys, per-minute counters
                                                                       ├─ Owner (one per account): its worlds, blueprints, swarms
                                                                       ├─ WorldObject (one per world): the engine's World in memory,
                                                                       │    its definition, last state, snapshots and environments in SQLite
                                                                       └─ SwarmObject (one per swarm): the plan and the arms' worlds
  • One world, one object. A world is a value in an isolate's memory, so each lives in its own Durable Object, which every request about it reaches. The object writes the world's exported state to its SQLite storage at most 1.5 s after the last change (and at once on a snapshot, a restore or a topology change); an object evicted or restarted rebuilds the world from the definition and that state, at the same hash, and reconnects its environments. A change in the last 1.5 s before a crash of the object is lost.
  • The engine in a Worker. workerd will not compile WebAssembly from bytes it fetches, so the lazy build is used: its engine (all five platforms and FreeCAD, 2.71 MB gzipped) and every code module it can link (the service kinds, the HTML engine, JavaScript, SQLite, the snapshot code, the renderer) are bundled into the script as compiled modules, 7.64 MB gzipped in all. What is only data — the render bundle, fonts, the internet's world — is served from the Worker's static assets and read into the module host before the engine asks for it (fonts and the internet at boot, the render bundle before the first frame). FreeCAD is compiled into the engine (CW_WASM_KEEP=freecad); the other heavy applications' modules do not all fit beside it, so they are spread over three Workers, the hosts.
  • Swarms. Each arm's runner lives in its world's object beside the world; a SwarmObject coordinates the rounds, the fork, the journal and the summary.
  • Staging. npm run engine copies the engine and the catalog (every worlds/* world, procedure, society and recorded journal) and boots each catalog world with that engine, marking those that do not boot. Content moves faster than the engine: build both from the same tree before a deploy.

Hosts#

A Worker's script is limited to 10 MB gzipped, and the engine with its code modules is 7.64 MB of it, so the seven application modules (6.0 MB) cannot all ride beside it. Each host is a Worker carrying the same engine and code modules and some of the application modules, and exporting the same WorldObject class; the API Worker reaches the other hosts' objects through Durable Object bindings to their scripts (cloud/hosts.json, cloud/src/hosts.ts):

hostWorkerapplication modulesscript, gzipped (wrangler)
corecomputerworld (the API itself)KiCad, Python9,048 KiB
officecomputerworld-officeSpreadsheet, Excel, Database; VS Code9,509 KiB
mediacomputerworld-mediaClipchamp, iMovie, Kdenlive, Video Editor; Music; Maps9,185 KiB

FreeCAD is in the engine itself (CW_WASM_KEEP=freecad, +0.51 MB gzipped): a procedure's clicks on a model and its cad_matches checks need the CAD kernel in the engine, so every host runs CAD.

The engine calls a module synchronously, in the world's own isolate, so a world and every module it runs share one host. A world's host is chosen when it boots and is part of its id: w_<hex> on core, w_<hex>-office, w_<hex>-media; an actor token carries the id, so any request reaches the right object without a lookup.

  • POST /v1/worlds {"world": ..., "procedure": {"world": ..., "id": ...}} boots a world for a procedure: on a host carrying every application module its phases launch, and with the desktops the procedure names (as Procedure::prepare does before a world boots).
  • With no procedure, the host carrying most of what the world's machines can launch is chosen, core on a tie; host names one.
  • POST /v1/worlds/{id}/fork {"host": "media"} moves a copy to another host at the same state hash (export, then import there: every host runs the same engine).
  • procedures/run on a world whose host lacks one of the procedure's modules is a 409 naming the host to fork to; on a world booted without the procedure's desktops, a 409 saying to boot one for it.

The catalog says where everything goes: GET /v1/catalog/worlds gives each world's app_modules and default host, GET /v1/catalog/procedures each procedure's app_modules and host, and GET /v1/catalog/vocabulary the hosts and what each carries. A world's summary and a run's answer carry host and memory (the engine's Wasm memory and each application module's instances).

Measured deployed (2026-10-28), each catalog procedure run with procedures/run in a world booted for it (POST /v1/worlds {world, procedure}): 46 of 52 pass, against 2 of the 15 domain procedures tried before the hosts. Wasm memory is the engine's after the run, which never shrinks; the limit is the isolate's 128 MB, with JavaScript beside it.

worlddomains that runhost(s)Wasm memory after the runwhat does not fit
pro-workstationcad (6/6), eda (5/5), office (4/4), media (3/3)media (CAD), core (KiCad), office9–78 MB—
company-2026gov (3/4), ops (4/4), biz (2/2)office99–121 MBswe (0/4): VS Code on the company world grows the engine to 135 MB; gov-late-sales-tax to 120 MB, and the isolate is reset
robotics-cocad, eda, fin, mkt, ops, sales, swe (14/14)office, core50–97 MB—
lantern-mediamed (2/3)media57–64 MBmed-video-claim-dispute: rendering in Kdenlive grows the engine to 169 MB and the video module to 13 MB
agent-desktop2/2office7–10 MB—

The sizes in that column are the engine's own; run in Node from the same build, one world per process, the failures peak at 120.2 MB (gov-late-sales-tax), 134.9 MB and 2.4 MB of VS Code (swe-atlas-clock-tests) and 168.6 MB and 13.1 MB of video (med-video-claim-dispute), so no host split makes them fit: they need the container below. A company world holds 40.6 MB in Node right after it boots and 76.5 MB in its object, which writes the world's first snapshot as it boots.

Limits of one object#

A Durable Object's isolate has 128 MB. agent-desktop holds 6.7 MB of Wasm memory; the internet world alone 42 MB; northstar-company-2026 76 MB in production (85 MB locally), with a 12 MB snapshot. Worlds of that size boot, act, render, snapshot and fork (a fork of the company world took 8.4 s end to end), but little more fits beside them: a society on an internet world (the test town's prefix arm held 93.5 MB after booting nine people) runs out of memory in its first round and answers 507 world_too_large. Locally (wrangler dev, no memory limit) the same swarms run, rumor-cascade included.

The way past it is the one commandagi's serverless devices took: a container. A Cloudflare Container (or Cloud Run) running Node with the same engine and the same object logic holds GBs; the Worker keeps accounts, keys and routing and sends a world marked large to it, with the same affinity rule (a world lives in one process; a request for it reaches that process). That is not built here. A smaller step would compile a society onto only the internet sites it uses instead of joining the whole internet, which changes its state hashes against the Python runner.

Measured#

Deployed (2026-10-28, client in the US, through computerworld.pages.dev's Function and the service binding), node cloud/scripts/e2e.mjs --base https://computerworld.pages.dev, client side:

p50
health (cold Worker)452 ms
sign-up415 ms
boot agent-desktop (a new object: its isolate, the engine, the fonts)1,721 ms
step (actor token)55 ms
scene84 ms
render 1280×800 PNG986 ms
snapshot / restore / export124 / 177 / 100 ms
fork (export, new object, import)1,635 ms
procedure run (legal-engagement, 34 GUI actions, fork)600 ms

A new world object reads 7 MB of fonts and data from static assets before the engine asks (0.4–1.1 s of its start, measured) and compiles the bundled engine on its first request; a resident world answers in tens of milliseconds. Booting company-2026 took 5.9 s.

On this development machine (aarch64, Node 26, wrangler dev, workerd 2026-10-23), client-side over loopback, from node cloud/scripts/e2e.mjs:

p50
sign-up (PBKDF2, 100,000 iterations)32 ms
boot agent-desktop (new object, new world)68 ms
step27.5 ms
scene21.9 ms
render 1280×800 + PNG (render 91–169 ms, encode 27–32 ms in the object)155 ms
snapshot32 ms
fork62 ms
procedure run (legal-engagement, 34 GUI actions)97 ms

Memory and start, in the object: the lazy engine instantiates in 28 ms; agent-desktop boots in 12–13 ms and holds 6.4 MB of Wasm memory, 29 MB after its first frame (the renderer's files and caches); northstar-company-2026 (the internet joined, 19 service kinds) boots in 142 ms and holds 85 MB, and its snapshot is 12.4 MB, exported in 141 ms. rumor-cascade (16 people, 3 arms) replays its recorded journal at about 12 s a round for the three arms.

performance.now() inside a deployed Worker only advances across I/O, so the *_ms fields the objects report (boot_ms, elapsed_ms, X-Render-Ms) read 0 there; measure from the client.

Running it locally#

cd cloud
npm install
# the hosted engine: CW_WASM_LAZY=1 CW_WASM_KEEP=freecad bash scripts/build-wasm.sh,
# pkg/web copied to target/cloud-engine/all/ (binaryen 121 or later for its code modules)
npm run engine           # stages engine/ (a bundle per host), public/_engine, public/_catalog,
                         # and each other host's wrangler.<host>.gen.json
npx wrangler dev --var WORLD_HOSTS:core   # the API alone, http://127.0.0.1:8787/v1
npx wrangler dev -c wrangler.jsonc -c wrangler.office.gen.json -c wrangler.media.gen.json
                         # the API and every host, bound to each other as when deployed
npm test                 # vitest: units, the API on the local runtime, a restart from storage
CW_CLOUD_HOSTS=1 npx vitest run test/hosts.test.ts
                         # a procedure of every domain, each on the host chosen for it
node scripts/e2e.mjs     # the end-to-end journey, with latencies

npm run engine -- --from <dir> stages another build; --engine full stages pkg/web (the whole engine) for local work only: 15.9 MB gzipped, over the limit. A .dev.vars with DEEPSEEK_API_KEY= gives local swarms the model.

Deploying#

The API is the Worker computerworld (its workers.dev route is off: it is reached only through the Pages project), and the site, the docs and the console are the Pages project computerworld, whose /v1/* Function forwards to the Worker through a service binding so the console's cookie is first-party. The project's custom domains are computaco.ai and www.computaco.ai, and the dev tier's project answers at dev.computaco.ai. cloud/pages/functions/_middleware.ts sends every other name a deployment answers to (www, and the projects' own *.pages.dev names and preview URLs) to its tier's one host with a 308, method kept, so a session cookie only ever lives on one host and nothing is reachable at a name we do not publish.

There are three tiers, always named (cloud/scripts/tiers.mjs), and no bare deploy:

TierWhereWorkersPages projectStripeSecrets
localwrangler dev, http://localhost:8787——test modecloud/.env.local → .dev.vars
devhttps://dev.computaco.aicomputerworld-dev, -office-dev, -media-devcomputerworld-devtest modecloud/.env.dev
prodhttps://computaco.aicomputerworld, -office, -mediacomputerworldlive modecloud/.env.prod

The env files are gitignored; cloud/.env.example is their layout. Dev and prod share nothing but the account: their own objects, accounts, rate-limit namespace and Stripe mode. Dev answers with x-robots-tag: noindex.

# the engine, from the same tree as worlds/: CW_WASM_LAZY=1 CW_WASM_KEEP=freecad bash
# scripts/build-wasm.sh, pkg/web copied to target/cloud-engine/all/
cd cloud && npm run engine -- --from ../target/cloud-engine/all
npm run deploy:dev           # hosts, API, secrets, Pages, then e2e against dev.computaco.ai
npm run deploy:prod          # the same for prod, e2e against computaco.ai
npm run env:prod             # what a secrets push would set (dry run); -- --apply to set it
npm run dev                  # local: writes .dev.vars from .env.local, then wrangler dev

deploy.sh deploys the world hosts before the API Worker, which binds to them. A tier's first deploy cannot: the hosts bind to the API Worker's AccountStore as well, so the script first puts up the API Worker without the hosts' bindings (wrangler.<tier>.first.gen.json), then the hosts, then the API Worker in full.

e2e.mjs and cloud/console/dev/check-console.mjs delete the accounts they make (DELETE /v1/me), also when a check fails. For accounts left over anyway, the Worker has two operator routes, present only when the secret ADMIN_TOKEN is set (wrangler secret put ADMIN_TOKEN) and a 404 to any request without it:

curl -s "$API/admin/users?prefix=e2e-" -H "authorization: Bearer $ADMIN_TOKEN"   # {users: [{id, username, created_at}]}
curl -s -X DELETE "$API/admin/users/e2e-…" -H "authorization: Bearer $ADMIN_TOKEN" # 204: as DELETE /v1/me

Payment runs on the Stripe account of CommandAGI INC., COMPUTACO INC.'s parent, in test mode for local and dev and live mode for prod. Each mode has the product Computaco Pro, its $29/month price (lookup key computaco_pro_monthly), a Customer Portal configuration of its own (the account's default is CommandAGI's), and per deployed tier a webhook endpoint <origin>/v1/billing/webhook for checkout.session.completed, checkout.session.expired, customer.subscription.updated, customer.subscription.deleted and invoice.payment_failed. Their ids are in each tier's env file as STRIPE_PRICE_PRO, STRIPE_PORTAL_CONFIGURATION and STRIPE_WEBHOOK_SECRET. Nothing reaches localhost, so locally stripe listen --forward-to localhost:8787/v1/billing/webhook stands in for the endpoint.

It needs a Workers Paid plan (each script is 9.3–9.7 MB gzipped; the free plan's limit is 3 MB) for SQLite-backed Durable Objects, the one secret above for swarms' model calls, and the site's own bundle built (site/pkg). Pages refuses files over 25 MiB and the site's full Wasm bundle is 41 MB, so build-pages.sh splits it into 20 MiB parts and the Function at /pkg/* joins them back into one response. The Worker's migrations keep the two classes the placeholder Worker was created with (v1, v2) and replace them in v3.