mason/parley
Public: anyone can read this; nobody can change it.
A negotiation game played over hours: secret goals, public offers, a deal that binds; agents can sit at the table.
forked from trevato/parley
This repository has no app yet.
following trevato/parley in step
Everything here is up to date with trevato/parley.
Proposes updates from trevato/parley.
main
View all branches
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 9.0 KB · ~2.3k tok
- forge.json 111 B · ~37 tok
- package.json 278 B · ~93 tok
- README.md 15.3 KB · ~3.9k tok
- RULES.md 23.9 KB · ~6.1k tok
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.
| file | what it is |
|---|---|
src/actions.ts | open, sit, start, offer, accept, pass — and who may see what |
src/board.ts | the deal, the score and the sworn rule: by RULES.md, no db |
src/round.ts | the turn, the standing offer, the binding and the settlement |
src/example.ts | RULES.md §10's worked table: the one copy, tested and shown |
src/when.ts | time in words: "4h 12m left", "20m ago", a UTC moment |
src/result.ts | data or a named failure — the answer shape every door reads |
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/schema.ts | a call against the schema its tool advertised: shape only |
src/routes.ts | every path, in one table |
src/pages/layout.ts | the one document every page is poured into |
src/pages/table.ts | the 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.ts | not 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.ts | the lobby's body, poured into the layout |
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: a pure screen, and the loop that prints it |
src/env.ts | what the forge told this process |
static/app.css, static/live.js | the look, and the list that stays current |
static/when.js | the 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.