The server behind the AI plugin for scmJS, the browser map editor for StarCraft: Brood War. It holds an Anthropic API key, a set of prompt recipes, and the rules for who may call them and how much they may spend. It runs in one container next to Caddy, which gets the certificate.
It knows nothing about maps. The plugin gathers what a recipe needs from the open map — the
tileset's terrain names, the map's statistics, a rendered picture, the trigger script's
declarations — sends it here, and applies what comes back with the editor's own tools. So a
map plan is a coarse grid of terrain names, not tiles; a trigger request comes back as a
script the editor compiles; a review comes back as findings with tile coordinates. The whole
contract is one file, src/protocol.ts, and the plugin carries an identical copy.
| Recipe | In | Out |
|---|---|---|
map-plan |
a prompt, the map size and tileset vocabulary, a player count and symmetry | a layout: legend + grid of cells, bases, ramps, doodads, units, locations, a name and description |
region-plan |
the same for one area, with the area as it is now | a layout for that area |
triggers |
a description and the map's generated .d.ts |
a trigger script and a summary; repair rounds carry the compiler's diagnostics |
explain-triggers |
triggers in the editor's text format, optionally a question | Markdown, streamed |
describe |
the map's facts | a name and description plus two alternatives |
briefing |
the map's facts and triggers | objectives and narration lines |
review |
a picture of the map, its facts, the checker's issues | a Markdown summary and findings with coordinates |
strings |
the string table and an instruction | the table rewritten |
agent |
a conversation, the plugin's tool definitions, the map's facts and a per-map reference block | the assistant's turn: text and tool calls |
Every answer carries the tokens it used, an estimated cost, and what the caller has left for the day.
The assistant's requests are arranged for the prompt cache: the system prompt and the plugin's reference block (the tileset's terrains, the unit table, the trigger vocabulary — the same text every turn) are cached system blocks, and a breakpoint sits on the last message the plugin sent, before the map-state block that changes every turn, so a long conversation with screenshots is read from the cache rather than re-billed each round.
You need Node 22 and an Anthropic API key.
npm install
ANTHROPIC_API_KEY=sk-ant-... AI_SERVER_ANONYMOUS=1 npm run dev
curl localhost:8080/v1/infoThat serves anyone on localhost:5173 (the editor's dev server) with the default per-address
limits. npm test runs the tests, npm run typecheck the compiler, npm run build writes
dist/.
A small VM is plenty: the server waits on Anthropic, it does not compute. You need a hostname pointing at the machine (an A record), ports 80 and 443 open, and Docker with the compose plugin.
git clone https://github.com/scm-js/ai-server && cd ai-server
cp .env.example .env # DOMAIN, ANTHROPIC_API_KEY, AI_SERVER_TOKENS, origins
cp config.example.yaml config.yaml # limits, recipes, pricing; the compose file mounts it, so copy it even unchanged
docker compose up -d
curl https://$DOMAIN/healthCaddy fetches a Let's Encrypt certificate for DOMAIN on first start. The image comes from
ghcr.io/scm-js/ai-server:latest, which the Publish image workflow rebuilds on every push
to main (tags v* also get their version). A package GitHub Actions publishes starts out
private: make it public once in the package's settings on GitHub (Package settings ▸ Danger
Zone ▸ Change visibility), or docker login ghcr.io on the VM with a token that can read
packages, or the VM cannot pull it. To update:
docker compose pull && docker compose up -dDay counters (requests and dollars per address, token and the whole server) live in
./data/usage.json and survive restarts; delete the file to reset everyone. The compose
file also starts a Postgres for the accounts (below); it sits idle while they are off. Logs are
docker compose logs -f server: one line per recipe call with the recipe, the caller's
kind and token name, the model, tokens, cost and duration. Prompts and outputs are not
logged unless logging.prompts is on — they are other people's maps.
When ports 80 and 443 are taken by a reverse proxy that serves other sites, do not start
this compose file's Caddy. docker-compose.proxy.yml starts only the server, bound to
127.0.0.1:8080:
docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d serverThen give the proxy a site for the hostname. For a Caddy on the same machine:
ai.example.org {
encode gzip
reverse_proxy 127.0.0.1:8080 {
flush_interval -1
}
}
If that Caddy runs in a container, 127.0.0.1 is the container's own loopback; use
host.docker.internal:8080 with extra_hosts: ["host.docker.internal:host-gateway"] on
it, or put the server on the proxy's Docker network and name the service. Keep
server.trustProxy on so the limits see real addresses, and keep flush_interval -1
(or nginx's proxy_buffering off) so the event stream is not buffered.
Behind Cloudflare's proxy, Cloudflare terminates the visitor's TLS. Its connection to the
origin is whatever the zone's SSL mode says: with Full the proxy needs a certificate of
its own for the hostname (Caddy's automatic one works when the zone does not force HTTPS
before the challenge, otherwise tls internal — Cloudflare accepts a self-signed origin
unless the mode is Full (strict)); with Flexible it connects over plain HTTP on port 80.
Cloudflare passes event streams through and closes an idle connection after 100 seconds,
which the 10-second heartbeats keep from happening.
Four kinds of caller, each with its own limits:
- Token.
Authorization: Bearer <token>, one of the tokens inaccess.tokens(orAI_SERVER_TOKENS). A token has a name for the log, its own limits over thelimits.perTokendefaults, and optionally a list of recipes and models. Its calls are paid for by the server's key and charged to its daily budget. - Own key.
X-Anthropic-Key: <key>— the caller's own Anthropic key, forwarded with the request and never stored or logged. Request limits still apply (limits.byok); there is no dollar budget, since it is their money. Off withaccess.byok: false. - Anonymous. No credentials. Off by default; on with
access.anonymous: true, limited per address bylimits.perIp, paid for by the server's key. - Session. A bearer the accounts module issued — a free trial or a signed-in account.
Limited by
limits.perUserand the account's role, paid for by the server's key and charged to the account's balance (unless the role is unlimited). See Accounts.
On top of those, limits.global caps the whole server's spend and concurrency. When a limit
is hit the answer is 429 (rate_limited), 402 (budget_exceeded) or 503
(too_busy) with a Retry-After header and the reason in the body. Budgets are charged
after each call from its real token counts and the price table (pricing in the config;
the built-in table has the list prices), so a caller whose budget is nearly out can still
make one more call.
Cost, for orientation, at Opus 5 list prices: a map plan is a few tens of cents, a trigger script about the same with a repair round, a review with a picture about ten cents, a name or a translation a cent or two. Cached prompts bring repeat calls down.
For people who want the AI features to work without finding a key or a token: turn on
accounts and the plugin's default access mode does the rest. The sequence is
- A free trial, no sign-in. The plugin makes a random device id once and asks
POST /v1/trialfor a session; it comes withaccounts.trial.usdon its balance. One trial per device id,accounts.trial.perIpPerWeekper address (a LAN party is one address), and the id lives in the browser's storage — clearing it gets another trial, which is why the amount is small. - Sign in for a weekly allowance. Through a provider (Discord to start with; the
provider interface in
src/accounts/providers.tsis where Google goes next). The plugin opens the provider in a popup, the callback page hands the session back to the editor withpostMessageand sets a cookie for the account page. Signing in from a trial session converts that trial into the account, so what was left of it carries over. An account is on a role:accounts.defaultRoleunlessaccounts.membersnames its identity. A role has aweeklyUsdthat refills at Monday 00:00 UTC and does not carry over — orunlimited: true, no balance at all — plus optional limits, recipe and model lists.membersmapsdiscord:<user id>to a role and is applied at every sign-in; anyone else moves with oneUPDATE users SET role = …. - Buy credit when that is not enough. Stripe Checkout, on Stripe's page; the server
never sees a card. The packs in
accounts.stripe.packssay what is paid and what lands on the balance — the defaults are the price less Stripe's fee, which is what selling at cost comes to. Purchased credit does not expire and is spent after the weekly allowance.
A person's account page is <publicUrl>/account: balance, ledger, top-up, linking another
provider ("new account, link later" — a sign-in with a different provider makes a new
account unless it is linked from here), sign out, and delete, which removes everything.
What is stored: the provider's id for the person, their display name, their email when the provider vouches for it (for the Stripe receipt), the session hashes, and a ledger of grants and charges. Never the map, the prompts, or the card.
Setting it up:
- Postgres. The compose file runs one;
DATABASE_URLpoints at it. The tables are created at start-up. AI_SERVER_PUBLIC_URL— where browsers reach the server, e.g.https://ai.example.org. The sign-in callbacks and the account page are built from it.- Discord. Make an application at discord.com/developers, add
<publicUrl>/auth/discord/callbackunder OAuth2 ▸ Redirects, and put its client id and secret inDISCORD_CLIENT_ID/DISCORD_CLIENT_SECRET. - Stripe (optional).
STRIPE_SECRET_KEY, and a webhook endpoint at<publicUrl>/v1/billing/webhooklistening forcheckout.session.completed, whose signing secret goes inSTRIPE_WEBHOOK_SECRET. Selling internationally is a tax question Stripe Tax can take on; that is a business decision, not a config key. server.allowedOriginsmust list the editor's origin: a sign-in only returns a session to a page on one of them.
The same server still takes tokens and BYOK keys; accounts are one more kind of caller.
Running the account system is /v1/admin/*, meant for the site or a script rather than
the plugin: an account whose role has admin: true calls it with its own session, and
accounts.adminTokens (AI_SERVER_ADMIN_TOKENS) are bearers for scripts. Put your own
Discord id on an admin role through accounts.members and you are in.
GET /v1/admin/overview— counts of trials and accounts (by role), this week's trials, sign-ins, charges and purchases, all-time totals, the roles, members, packs and providers.GET /v1/admin/roles,PUT /v1/admin/roles/:name(the role's settings as JSON:weeklyUsd,unlimited,admin,limits,recipes,models),DELETE /v1/admin/roles/:name(its accounts move to the default role). Roles live in the database; the config'srolesare seeds that never overwrite an edit.GET /v1/admin/users?q=&role=&kind=&limit=&before=— search by name, id, provider id or email, newest first, paged bybefore;GET /v1/admin/users/:idwith the ledger;PATCH /v1/admin/users/:id { role?, name? }(a new role's allowance fills at once);POST /v1/admin/users/:id/credit { usd, note? }(negative takes credit away);POST /v1/admin/users/:id/revoke(signs the account out everywhere);DELETE /v1/admin/users/:id.GET /v1/admin/ledger?limit=&before=&kind=&user=— every grant, charge and purchase across accounts, newest first.
Every write is logged with who did it. The shapes are Admin* in src/protocol.ts.
config.example.yaml documents every key. The file is optional (AI_SERVER_CONFIG names
it, default ./config.yaml); environment variables override it, and .env.example lists
those. A server with no config file and only ANTHROPIC_API_KEY and AI_SERVER_TOKENS set
is a reasonable one.
Per recipe you can turn it off, pin a model, and set the effort and output room. The
default model is claude-opus-5 everywhere; effort is tuned per recipe (high for plans and
triggers, low for names and translations). A caller may ask for another model from the
anthropic.models list with options.model, and for an effort with options.effort.
anthropic.fallbacks (on by default) asks Anthropic to re-run a request its safety
classifiers declined on the recommended substitute model, server-side. When the whole chain
declines, the answer is 422 (refused).
GET /health answers { ok, version }. GET /v1/info describes the server, its models and
recipes, which kinds of caller it accepts, what its account system offers (accounts), and
the caller's remaining allowance — for a session, the account behind it too.
With accounts on: POST /v1/trial { deviceId } → { session, account }; POST /v1/auth/start { provider, returnOrigin } → { url } to open in a popup (the callback
page posts { type: "scmjs-ai-auth", session, account } to the opener at returnOrigin);
POST /v1/auth/logout; GET /v1/account → { account, ledger }; POST /v1/billing/checkout { pack } → { url } of the Stripe page; POST /v1/billing/webhook
for Stripe. GET /account is the account page, GET /auth/:provider/callback the
provider's return.
POST /v1/recipes/<name> takes { protocol: 1, input, options? }. With
Accept: text/event-stream the answer is a stream of events — start, progress
heartbeats while the model works, thinking when options.thinking asked for the
reasoning summary, delta text for the prose recipes, one result with the output, usage
and remaining allowance, then done; an error after the stream started is an error
event. Any other Accept gets one JSON { id, recipe, output, usage, remaining } with
X-AI-Cost-Usd and X-AI-Budget-Remaining-Usd headers. Errors are
{ error: { code, message, retryAfterSec? } } with the matching status.
The shapes of every input and output are in src/protocol.ts.
src/index.ts start-up: config, usage file, listen, shutdown
src/config.ts the YAML + environment schema and loader
src/auth.ts who is calling, and their scopes
src/limits.ts minute and day counters, budgets, concurrency, the usage file
src/pricing.ts dollars per token
src/claude.ts the one Anthropic call (streaming, caching, structured output, fallbacks) and a fake for tests
src/sse.ts event framing
src/routes.ts /health, /v1/info, /v1/recipes/:name
src/accounts/ trials, sessions, sign-in providers, roles, balances and the ledger (service.ts), Stripe (stripe.ts),
the account page (pages.ts) and its routes (routes.ts), the admin API (admin.ts), the Postgres schema (db.ts)
src/recipes/ one file per recipe: input schema, system prompt, message builder, output parser
src/recipes/plan.ts the layout language the two plan recipes share
tests/ vitest, no network; the accounts suite runs the real SQL over PGlite
MIT.