scrims
A scrim board for the cs crew: announcements from an admin, matches anyone
signed in can put up, and a slot you take or give back. Three things, each
a resource in contract.json with its own API path, so the forge can knock
on every door.
- announcement — only an admin posts, edits or removes one; anyone,
signed in or not, reads and lists them.
- match — any signed-in person posts a match (map, time, slots); the
person who posted it, or an admin, edits or cancels it; anyone reads and
lists.
- signup — a signed-in person joins a match once and can leave it; the
match's poster and an admin can see who signed up; anyone can see how
many slots are left, never who.
All times are UTC. Slots left is slots − signups, floored at zero, and
the count is on the match, which everyone may read — the names are on the
signups, which they may not.
This is an app: the forge runs it. Production follows the default branch;
every open pull gets a preview at its own address with its own copy of the
data. Everything the app keeps lives under FORGE_APP_DATA, which is what
makes "production data" and "preview data" two different things.
PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts
bun test
FORGE_APP_DATA=./data bun run src/tty.ts
FORGE_APP_DATA=./data bun run src/db.ts --check
Nothing here is simulated: the board is real SQLite, the identities are the
forge's signed ones, and /events is a real stream. Without the forge in
front of it — running it by hand — nobody is signed in, so the page is the
guest's view: matches and counts, no forms.
The shape
One implementation, many doors. src/actions.ts is every operation as a
plain function — (db, who, input) in, data or a named failure out — and
the page, the JSON API, the MCP server and the terminal all call it and
decide nothing themselves. AGENTS.md says how to add to it.
| file | what it is |
src/actions.ts | announcements, matches, signups: every rule, once |
src/time.ts | a match time: read as UTC, stored in one shape, shown to a person |
contract.json | who may do what: roles, and the least role each verb needs |
src/contract.ts, src/can.ts | the table, read at boot; the forge's evaluator, verbatim |
src/who.ts | who is asking, from the forge's signed headers |
src/db.ts | SQLite under FORGE_APP_DATA, WAL, forward-only migrations |
src/live.ts | the in-process bus and GET /events |
src/mcp.ts | the actions as MCP tools at POST /mcp |
src/routes.ts | every path, in one table |
src/page.ts | the board, rendered on the server |
src/http.ts | the floor: headers, body cap, same-origin, rate limit, files |
src/server.ts | boot: open the database, serve |
src/tty.ts | the terminal, over the same actions |
src/env.ts | what the forge told this process |
static/app.css, static/live.js | the look, and the board that stays current |
No dependencies: Bun, bun:sqlite and the standard library are all of it.
Who you are
The forge says who is asking on every request it proxies, and the app
verifies the signature (src/who.ts); nothing else is trusted. Three
identities, and two roles a row hands out:
- anon — anyone. Reads the announcements, the matches and the counts.
- user — signed in to the forge and able to see this app. Puts up
matches, joins and leaves them.
- author — held by whoever a row names: the person who put up a match
edits and cancels it, the person who joined can leave.
- host — held by the match's poster, named on each signup (
host_id),
so they can see who signed up for their own match.
- admin — able to write this repository. Posts announcements, and may
do anything the roles below can.
"Sign in" is /__forge/signin?to=<path> and "Sign out" is
/__forge/signout; the forge answers both on the app's own host.
Any interface
The same actions, the same rules, at every door. A script is the person
whose forge grant it carries; a bare script is anonymous.
curl https://<app>/api/matches
curl https://<app>/api/announcements
curl -X POST https://<app>/api/matches -H 'content-type: application/json' \
-d '{"map":"de_dust2","starts_at":"2026-09-30T20:00","slots":10}'
curl -X POST https://<app>/api/signups -H 'content-type: application/json' -d '{"match_id":1}'
curl https://<app>/api/signups?match=1
curl -X DELETE https://<app>/api/signups/1
A time may be written 2026-09-30T20:00 (UTC), with seconds, with a Z,
or with an offset such as +02:00, which is converted. It comes back as
2026-09-30T20:00:00Z.
As an MCP server, for Claude Code or Claude Desktop —
list_announcements, post_announcement, remove_announcement,
list_matches, post_match, edit_match, cancel_match, join_match,
leave_match, list_signups:
claude mcp add --transport http scrims https://<app>/mcp
{ "mcpServers": { "scrims": { "type": "http", "url": "https://<app>/mcp" } } }
The board stays current over /events: a match someone puts up, or a slot
someone takes, shows without a reload. What goes on the wire is the id and
the count, never a name — the page each viewer then refetches is rendered
for them. Without JavaScript it is the same page, one reload behind.
The terminal opens with the exact command in FORGE_APP_SSH when the app
runs; the page and the terminal both print that command verbatim, and say
so when it is unset. It is src/tty.ts on a real pty over the same data,
as the account at the keyboard: j/k move, s join, l leave, n a new
match, c cancel, a announce, q quit.
The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or
preview), FORGE_APP_URL, FORGE_APP_COMMIT, FORGE_APP_SSH and
FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.