Skip to content

Repository files navigation

ai-server

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.

Recipes

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.

Running it

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/info

That 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/.

Deploying on a VM

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/health

Caddy 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 -d

Day 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.

Behind a proxy you already run

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 server

Then 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.

Who may call it

Four kinds of caller, each with its own limits:

  • Token. Authorization: Bearer <token>, one of the tokens in access.tokens (or AI_SERVER_TOKENS). A token has a name for the log, its own limits over the limits.perToken defaults, 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 with access.byok: false.
  • Anonymous. No credentials. Off by default; on with access.anonymous: true, limited per address by limits.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.perUser and 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.

Accounts

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

  1. A free trial, no sign-in. The plugin makes a random device id once and asks POST /v1/trial for a session; it comes with accounts.trial.usd on its balance. One trial per device id, accounts.trial.perIpPerWeek per 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.
  2. Sign in for a weekly allowance. Through a provider (Discord to start with; the provider interface in src/accounts/providers.ts is where Google goes next). The plugin opens the provider in a popup, the callback page hands the session back to the editor with postMessage and 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.defaultRole unless accounts.members names its identity. A role has a weeklyUsd that refills at Monday 00:00 UTC and does not carry over — or unlimited: true, no balance at all — plus optional limits, recipe and model lists. members maps discord:<user id> to a role and is applied at every sign-in; anyone else moves with one UPDATE users SET role = ….
  3. Buy credit when that is not enough. Stripe Checkout, on Stripe's page; the server never sees a card. The packs in accounts.stripe.packs say 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_URL points 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/callback under OAuth2 ▸ Redirects, and put its client id and secret in DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET.
  • Stripe (optional). STRIPE_SECRET_KEY, and a webhook endpoint at <publicUrl>/v1/billing/webhook listening for checkout.session.completed, whose signing secret goes in STRIPE_WEBHOOK_SECRET. Selling internationally is a tax question Stripe Tax can take on; that is a business decision, not a config key.
  • server.allowedOrigins must 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.

Admin API

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's roles are 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 by before; GET /v1/admin/users/:id with 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.

Configuration

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).

The API

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.

Layout

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

License

MIT.

About

Deployable server container for scm-js's experimental ai plugin

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages