trevato/parley

Public: anyone can read this; the crew can change it.

A negotiation game played over hours: secret goals, public offers, a deal that binds; agents can sit at the table.

Running 6ecafc0 https://parley--forge-trevato-apps.open-platform.sh in production 10d 23h
History

README.md

parley

A negotiation game played over hours: secret goals, public offers, a deal that binds; agents can sit at the table.

The game is Sworn & Said, and its rules of record — charter, charges, scoring, the sworn and the said, quorum and the clock — are written once in RULES.md. If you have never played, start at /rules instead: the game in two minutes, with a real charter, a real charge and what an accord pays it. The record is served beside it at /rules.md, and where the two disagree the record wins.

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   # run it here
bun test                                                # the check every pull runs
FORGE_APP_DATA=./data bun run src/tty.ts                # the terminal
FORGE_APP_DATA=./data bun run src/db.ts --check         # schema version, integrity

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.

/style is where the look lives: every part a table is made of — the header's three doors, the charter grid, sworn and said claims, the act log, the clock slot, the offer form — rendered from a fixture in src/pages/style.ts, never from a table, so it reads no database and nobody's identity. The fixture is the worked example of RULES.md §10, and src/pages/style.test.ts holds it to those rules: a styleguide that disagrees with the game teaches the wrong thing twice. The table page renders the same functions from real state.

/rules teaches the same game those parts play. Every number on it is read from the constants — SEATS_MIN, CHARTER_CLAUSES, the weights, the quorum, the clock's defaults — and what the example accord pays the example charge is score(), run at render time. Nothing numeric is written into its prose, because two copies of the rules is how a game ends up with a wrong one; anything procedural it leaves out is a sentence pointing at the record.

filewhat it is
src/actions.tsopen, sit, start, offer, accept, pass — and who may see what
src/board.tsthe deal, the score and the sworn rule: by RULES.md, no db
src/round.tsthe turn, the standing offer, the binding and the settlement
src/example.tsRULES.md §10's worked table: the one copy, tested and shown
src/when.tstime in words: "4h 12m left", "20m ago", a UTC moment
src/result.tsdata or a named failure — the answer shape every door reads
src/who.tswho is asking, from the forge's signed headers
src/db.tsSQLite under FORGE_APP_DATA, WAL, forward-only migrations
src/live.tsthe in-process bus and GET /events
src/mcp.tsthe actions as MCP tools at POST /mcp
src/schema.tsa call against the schema its tool advertised: shape only
src/routes.tsevery path, in one table
src/pages/layout.tsthe one document every page is poured into
src/pages/table.tsthe parts a table is made of: charter, claims, acts, clock
src/pages/tableview.ts/tables/:id, one table at its own address
src/pages/missing.tsnot found, as a page for a browser and a word for a script
src/pages/style.ts/style, where the look lives
src/pages/rules.ts/rules, the game in two minutes, and /rules.md behind it
src/page.tsthe lobby's body, poured into the layout
src/http.tsthe floor: headers, body cap, same-origin, rate limit, files
src/server.tsboot: open the database, serve
src/tty.tsthe terminal: a pure screen, and the loop that prints it
src/env.tswhat the forge told this process
static/app.css, static/live.jsthe look, and the list that stays current
static/when.jsthe words for a time, run by the server and the browser both

The table

A table is a game. Someone signed in opens one — a title, four to six seats, a turn window and a deadline — and takes the first seat by doing so; anyone else with a forge account sits down while seats are left, and may give the seat up again until play starts. The fixed numbers are RULES.md §2 and not the app's to invent: a turn window of 15 minutes to 48 hours (6 hours by default), a deadline of an hour to a fortnight (24 hours) that is never shorter than two turn windows, and a quorum of ⌈n/2⌉+1 — 3 of 4, 4 of 5, 4 of 6 — which follows from the seat count, so no host chooses it.

Every table has an address of its own — /tables/:id — and that address is the whole of it: the title, who opened it, its status, the seats in order with who holds them, the clock it will play on and the quorum that binds an accord. Taking a seat and giving it up happen there, so the lobby is a list of doors and nothing else; the page shows its own absolute URL for you to copy and send to whoever you want at the table. Watching needs no account: a guest reads it like anyone else, redacted the same way, and signing in is how you sit down. The seats fill in as people arrive without a reload (data-live="seat.joined seat.left table.started"), and the first paint is the whole page. Nothing of the game itself — the charter, the turn, the offer, the reveal — is on that page yet: the turn is played over the JSON API today, and the page grows the charter, the act log and the three buttons next.

Following a stale link is a page too, not four words on a white screen: any address a browser asks for HTML on answers with the app's own layout, a sentence and the way back to the lobby (src/pages/missing.ts). The JSON API keeps its plain body and the status that goes with it.

What a seat is playing for is its charge, and it is secret: the action that reads a table redacts it by viewer, so no door can leak it.

  • a guest or someone not seated sees the charter, the seats, the status

and the whole act log — and no charge at all;

  • a seated player sees their own charge and nobody else's;
  • once the table is settled, everyone sees every charge.

The game's seed is the charges of every seat written another way, so it never leaves src/actions.ts either. What a charter and a charge contain, and what an accord pays, are RULES.md and src/board.ts; whose turn it is, what stands, when it binds and what the settlement comes to are src/round.ts, which reads the acts as plain data and writes nothing.

The host starts the table once four to six seats are taken: that deals the charter and every seat's charge from a seed the app makes and never shows, fixes the order of the seats for good, sets the quorum for the seats that actually took, and starts the clock. From then on the seat whose turn it is takes exactly one act — offer an accord, accept the one that stands, or pass — and the ledger of those acts is the whole state of the game. Whose turn it is, what stands and who has signed it are derived from that ledger on every read, never stored a second time: the turn is the act count modulo the seat count, the standing offer is the latest offer, and its acceptances are the accept acts after it. A new offer therefore clears every acceptance by existing, and there is no pointer or tally that can fall out of step with the record.

Reading a table therefore answers the two questions a player has, once, for every door: may is what your seat may do right now — offer, accept, pass, the one sentence refusing each of them where it is refused, and cite, the aims RULES.md §6 would let you swear on the standing offer, in charter order — and your_payoff is what that offer would pay your seat if it bound. Both are your own seat's and nobody else's: a guest, a watcher and another player are handed may: null, your_payoff: null and no aim of anyone's, and the page, the terminal and the MCP tools show a playable turn without one of them re-deriving a rule. Neither is stored: may is src/round.ts answering and your_payoff is score() in src/board.ts, over acts already read.

Because the turn is a count of that ledger, two doors of one seat could read the same count at the same instant and both believe they hold the turn. So the append is one immediate transaction that re-reads the acts and asks the rules again inside itself, and the row it writes carries the turn it took, unique per table: the second writer of a turn is refused, told plainly that it is not their turn, and nothing it sent is half written.

A table today is opened, sat at, left, started and played. Scoring the accord that reaches quorum, the reveal and the clock that passes for a seat that never played are still to come; until then a table that binds stops taking acts and waits for its settlement.

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 tiers:

  • anon — anyone. Reads.
  • user — signed in to the forge and able to see this app. Opens tables,

takes one seat at any of them, and gives their own seat up.

  • admin — able to write this repository. Writing the repository is not

sight of a secret: an admin who holds no seat reads a table exactly as a guest does, charges and all redacted.

"Sign in" is /__forge/signin?to=<path> and "Sign out" is /__forge/signout; the forge answers both on the app's own host.

Because the answer depends on who asked, nothing between the app and the person keeps it: every page, JSON answer and redirect is Cache-Control: private, no-store with Vary: x-forge-identity, x-forge-signature, cookie, so no shared cache, CDN or browser history can hand one player a view redacted for another. The cost is the back button: going back refetches rather than restoring instantly. Files under /static are the exception — they are the same for everyone and are cached for a year when linked by content — and a response that is genuinely public says so at its call site: /style, /rules and /rules.md read no table and nobody's identity, so they are public, max-age=60 and vary on nothing a person carries.

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/tables
curl https://<app>/api/tables/1
curl -X POST https://<app>/api/tables -H 'content-type: application/json' \
  -d '{"title":"The Wreck of the Fathom","seats_max":5}'
curl -X POST https://<app>/api/tables/1/join
curl -X POST https://<app>/api/tables/1/leave

# The host deals the table and starts the clock, then the turn goes round.
curl -X POST https://<app>/api/tables/1/start
curl -X POST https://<app>/api/tables/1/offer -H 'content-type: application/json' \
  -d '{"accord":{"beacon":"kept","hold":"sealed","ledger":"redacted","pilot":"reinstated","name":"kept","chart":"locked","salvage":"to_the_finders"}}'
curl -X POST https://<app>/api/tables/1/accept
curl -X POST https://<app>/api/tables/1/pass

An accord is one setting for every clause of that table's charter, which GET /api/tables/1 gives you. Accepting costs a truth (RULES.md §6): the answer swears the first of your own aims the accord satisfies, or the one you name — -d '{"cite":{"clause":"name","setting":"changed"}}' — out of those it lets you swear, which are the ones may.cite in that same state lists for you.

A refusal answers with its name and the status that matches it: signin_required 401, forbidden 403 (no seat of yours here), not_found 404, conflict 409 (the table was in no state to take the request: not your turn, nothing to accept, already accepted), invalid 422 and rate_limited 429. An id is plain digits and nothing else, so one table has one URL: /api/tables/1e3 and /api/tables/1.0 are 404, not other ways of asking for 1000 and 1.

As an MCP server, for Claude Code or Claude Desktop — list_tables, table_state, open_table, join_table, leave_table. Every tool publishes a schema and is held to it: an argument of the wrong type, one the tool does not have, or a required one left out comes back as isError with one sentence naming the argument and what was expected, so a model can correct itself rather than be quietly given something else (src/schema.ts); a rule of the game refuses the same way, under its own name. To connect:

claude mcp add --transport http __TITLE__ https://<app>/mcp
{
  "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } }
}

The page stays current over /events: a table someone else opens, sits at, leaves or starts appears without a reload. Without JavaScript it is the same page, one reload behind.

Times are said the same way everywhere — "4h 12m left", "20m ago", "14 Sep 2026, 09:41 UTC" — because the server and the browser format them with the same file (static/when.js, typed and wrapped in markup by src/when.ts). The server writes the instant into the <time datetime> in UTC and never guesses a zone; the browser re-renders the words from that same attribute once a second — one interval for the whole page, stopped while the tab is hidden, and not a single request — and puts the reader's local time in the hover text. Without JavaScript the words are right at paint and then stand still.

The terminal opens with the exact command in FORGE_APP_SSH when the app runs (ssh -p 2222 <name>@<forge> — the app is the login name, the forge the host; <name>--<pull>@<forge> for a preview's). It is src/tty.ts on a real pty over the same data, as the account at the keyboard: j/k move between tables, o opens one, s sits down, x gives the seat up, q quits. That file is two halves: screen(state) draws a frame as a pure function — state in, lines out, no database and no environment, so every screen it can show has a test in src/tty.test.ts — and the loop around it reads keys, calls the actions and prints what comes back. When FORGE_APP_SSH is unset the header says the terminal is off rather than inventing a command.

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.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs