mason/tempo
Public: anyone can read this; nobody can change it.
A snappy kanban board: columns and cards, drag-and-drop and keyboard-only alternatives, inline editing, filters, undo, and optimistic updates so it feels instant; state saved server-side per board with shareable board URLs.
forked from trevato/tempo
This repository has no app yet.
following trevato/tempo in step
Everything here is up to date with trevato/tempo.
Proposes updates from trevato/tempo.
main
- src
- static
- .gitignore 37 B · ~11 tok
- AGENTS.md 2.4 KB · ~617 tok
- bun.lock 2.0 KB · ~676 tok
- forge.json 111 B · ~37 tok
- package.json 198 B · ~66 tok
- README.md 18.0 KB · ~4.6k tok
README.md
tempo
A snappy kanban board. Open /, create a board, and share its URL —
anyone with the link can see and change it as a guest, the same way a
shared document works. Sign in through the forge and a board you create
or claim stays in your list at /, so you don't have to keep the link
yourself. Two people (and an agent, over MCP) on the same board see each
other's moves within a second, no reload.
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 interface
Using it
- Create a board from
/, then share/b/<id>— that id is the
address anyone with the link uses; it is still the only thing that controls access (see "What's simulated").
- Sign in (top right, a link to the forge — tempo has no sign-in
form of its own) and a board you create is yours from the start; an existing guest board gets a "Keep this board" button that adds it to your list the first time anyone signed in presses it. It never changes hands after that.
- A guest's boards live in that browser only:
static/boards.js
remembers, in localStorage, every board id (and name) you've opened
as a guest, and lists them on / with a note that they're local to
this browser and a Sign in link. It only records a visit made as a
guest, so signing out on a shared machine never leaves the next
anonymous visitor looking at someone else's board links; / lists
that same local set for a signed-in person too (labelled as not
necessarily theirs), so a board someone else owns that they merely
opened doesn't just disappear. Signing in doesn't move a board into
your kept list by itself — open one and press "Keep this board" to
add it for good.
- Cards: type a title and press Enter to save it; drag a card to
reorder or move it between columns, or focus it (click, or Tab) and press the arrow keys — the keyboard does everything dragging does. On a touchscreen, press and hold a card (~300ms) to pick it up, then carry it with your thumb; a short swipe still scrolls the board, and holding a card near the left or right edge scrolls it into view. A card also has a "Move to" dropdown and a Top/Bottom position, both plain forms that work with JavaScript off.
- Columns: add, rename, delete, and move them left/right with the
◀ ▶ buttons next to each name.
- Filter: the search box hides non-matching cards instantly (no
round trip) and keeps itself in the URL, so a filtered view is shareable too.
- Undo: reverses the last change to the board — a create, edit,
move, or delete of a card or column. It is a shared, board-wide undo (there's no per-person history), which fits a board anyone can edit.
- Live: every write publishes one event on an in-process bus
(src/bus.ts), scoped to its board; static/board.js opens
/b/<id>/events (server-sent events) and applies each one — a card
or column that's added, moved, renamed or deleted lands in the right
spot within a second, counts stay current, and neither your scroll
position nor whatever you're mid-typing elsewhere on the page is
touched. A change you just made yourself isn't re-applied a second
time (the event echoes back your tab's own id, and your tab ignores
it). An edit or a rename you're in the middle of typing waits for a
conflicting live one until you're done, rather than overwriting it.
If the connection drops, the browser reconnects on its own; that
reconnect re-reads the whole board once to reconcile, rather than
trying to replay whatever was missed while it was down. Claiming a
board doesn't publish an event — "Kept by" is the one piece of board
state that only updates on next load, a deliberate narrowing (see
"What's simulated"). A small dot and label next to the board's name
say whether that connection is actually up: "Live" once it's open,
"Not live — reconnecting" if a drop lingers past about a second (a
quick blip never gets a word), and "Not live — Reconnect" with a
button if the browser has given up retrying on its own — which per
the EventSource spec is permanent for any non-200 response, including
the 503 this same server answers with once a board has 100 tabs open
on it (MAX_SUBSCRIBERS_PER_BOARD in src/limits.ts). The button
opens a fresh connection and, once it's up, reconciles the same way
an automatic reconnect does. A move made in the terminal (src/tty.ts,
a separate process) reaches the same open page too — see src/watch.ts
below — arriving as a whole-board reconcile rather than one placed
card, since the poller that notices it only knows the board changed,
not what changed. src/live-e2e.test.ts is where the direction's own
sentence — two people and one agent on one board, each seeing the
others' moves as they happen — is checked end to end, over real HTTP
and a real /events stream rather than the bus directly; it's the
first place to look when live updates stop working.
- Presence: the header shows who else has this board's
/events
stream open right now — a forge username for a signed-in visitor (once,
even if they have it open in two tabs), "Guest" for one anonymous tab,
"N guests" for several. It means exactly that: this process's other open
connections to this board, right now, nothing more. It isn't who has
ever opened the board, isn't a cursor or an edit lock, and a guest tab
counts as one whether or not it's the same person as another guest tab —
there's no identity to tell them apart with, the way there is for a
signed-in name. It updates within a second of someone arriving or
leaving (src/presence.ts, fed only by the stream's own subscribe/
unsubscribe — never polled, and coalesced to at most one push per board
every 250ms so a burst of arrivals or departures can't be used to flood
every other open tab with a frame per event). A tab that closes
properly is gone as soon as it does; a tab that vanishes without closing
(a killed browser, a dead network) is a weaker guarantee — it's gone once
the underlying connection actually times out, not a bound this app
enforces itself, and the 25s heartbeat is a write from the server, which
can keep that connection looking alive well past a minute even after the
browser is gone. A card changed by someone else is also marked "by
\<name\>" for a few seconds (a guest's changes carry no name, so they
aren't marked) and announced once for a screen reader; a change you made
yourself is never marked. Presence is web-only: it comes from
/events subscribers, so a person working the same board from the
terminal (src/tty.ts) neither appears in the list nor sees it.
- Agents:
POST /mcpspeaks MCP over plain JSON-RPC 2.0
(initialize, tools/list, tools/call) with eight tools —
list_boards, list_columns, list_cards, add_card, move_card,
edit_card, delete_card, add_column — under the same forge
identity as the page. list_boards only ever returns boards the
caller has kept; the other seven take a board id you already have,
exactly like the page does. The call order a model needs is in the
tools' own descriptions: list_columns before add_card (a
column_id comes from there, not from a column's name — two columns
can share one), and list_cards before move_card, edit_card and
delete_card (a card_id comes from there, not from a title). Each
write validates exactly as its page and terminal counterpart does —
the same trimmed, length-capped title or name, the same board caps —
and its bus event carries the caller's identity, so it shows up on
every open page within a second. Every tools/call result carries
its value twice — structuredContent, matching the tool's declared
outputSchema, and the same JSON as text for a client that only
reads that — so nothing has to parse an id back out of prose. Left
out on purpose: undo (an agent reversing a person's last change is
its own decision) and deleting a column (it would cascade every card
in it); a deleted card is still undoable from the page itself.
/mcp is stateless — no Mcp-Session-Id, no GET /mcp stream —
every call stands on its own, so initialize negotiates nothing
that outlives the request. It speaks protocol versions 2025-06-18
and 2024-11-05 (src/mcp.ts's PROTOCOL_VERSIONS, newest first):
initialize echoes back whichever of those the client asked for, or
the newest one if it asked for something else; a later request's
MCP-Protocol-Version header naming anything else is refused with a
JSON-RPC error that names the versions this server does speak. Only
tools are advertised — resources/list and prompts/list answer
-32601 because there is nothing behind them to list, not because
they were missed.
- Concurrent drags: two people dragging cards at once never tear
each other's write — every move runs in one transaction — and cards are ordered by position, then id, so even a rare tie still sorts the same way for everyone.
The first paint is plain server-rendered HTML; static/board.js (named
by its content hash, cached for a year) then adds drag-and-drop,
keyboard reordering, live updates and optimistic updates — every action
updates the page immediately and reconciles with the server in the
background, reverting if it fails.
What's simulated
- A board's id is still its access control, exactly as before: signing
in adds attribution and a list you can find it in later, not privacy. Anyone with the link can still see and edit any board, signed in or not — tempo never asks a visitor to sign in just to use a board they were given the link to.
- tempo asks crawlers to stay out of
/b/(and/mcp): every response
under those paths carries x-robots-tag: noindex, nofollow, noarchive,
a board page repeats it in a <meta name="robots">, and /robots.txt
says the same — naming prefixes only, never a board id. That closes the
ordinary accident, a board link pasted somewhere public ending up in a
search index; it is a request, not a defence. A board link is still a
capability, and anyone who has it — crawler included — has the board.
/ and /static/* are left indexable on purpose: the landing page is
the front door and names no board.
- Because of that,
list_boardsover MCP is deliberately narrow: it
answers only with boards the caller has kept, never every board on the server, so it can't be used to go discover a board by guessing.
- Undo is board-wide, not per-visitor, and unaffected by who's signed
in.
- Claiming a board publishes no live event — it isn't one of the kinds
in src/bus.ts — so another open tab's "Kept by" only catches up on
its next load, not live like everything else on the board.
- The terminal (
src/tty.tsdraws it and reads keys,src/keys.ts
decides what each key does, src/session.ts decides who is asking)
mirrors the board actions with a letter-key scheme (j/k/h/l move,
H/J/K/L move the focused card, a/e/d add/edit/delete, c/r/D
add/rename/delete a column, f filter, u undo) rather than
drag-and-drop, since a terminal has no mouse. An ssh session carries no
x-forge-identity header, but the forge sets
FORGE_APP_USER_ID/FORGE_APP_USER_ROLE in the process environment for
a session it has already authenticated, so the terminal reads those
instead (src/session.ts) — the board picker is then scoped exactly
like list_boards over MCP: a guest sees no boards at all, a signed-in
person sees only their own, never another person's or anyone's unowned
ones. Unlike the header, nothing here is signed: this trusts that the
forge's sshd does not forward a client-supplied environment for those
two names (no matching AcceptEnv) — unconfirmed as of this
writing; if it turns out sshd does forward them, a client can set
FORGE_APP_USER_ID to someone else's id and read that person's board
list, uncaught, since there's no signature here the way there is on
x-forge-identity. o opens a board by id regardless of who owns it, the same way
a shared link works on the page, so a board someone hands you is still
reachable. A board the terminal creates is still unowned, same as a
guest's on the web — there's no username in those two env vars to
attribute it to, and it has no "keep this board"; ownership only ever
changes through the web, where it can be. Board mode's header shows the
board's /b/<id> address the way the picker does, so a board made with
n — unowned, and so invisible to the picker even for the person who
just made it — stays reachable afterwards.
- The event bus (
src/bus.ts) is this one process's in memory, and the
terminal is a separate process on a pty, so it can't hear it. Instead the
terminal follows the same board live by polling: src/db.ts keeps a
revision per board (and one for the boards list) bumped in the same
transaction as every write, and src/tty.ts checks it about twice a
second while idle, redrawing only when it moved — so someone else's
card move, edit, or new board shows up within half a second whether it
came from the page or another terminal. A redraw never disturbs what
you're doing: an open prompt keeps its text, the filter stays, and the
cursor rides the same card or column (found by id) rather than jumping
when the list changes under it. The other direction rides the same
revision counter: src/watch.ts polls it, about twice a second, for
every board with an open /events listener, and publishes one external
event (by: null) when it moved without this process having published it
itself — so a card moved in the terminal reaches an open web page within
a second too, the same way a live edit from another tab does.
Limits
A board anyone can open and change has nothing else standing between it
and the disk, so src/limits.ts names a floor:
- Undo keeps only the newest 200 actions per board; past that, undoing
just runs out ("nothing to undo"), the same path as an empty log.
- Columns: a board holds at most 50. The 51st is refused with a
message naming the number, both on the page and in the terminal.
- Cards: a board holds at most 500 total, refused the same way.
- Board creation is throttled by a single, in-memory token bucket for
the whole process (30 a minute) — a crude, app-wide brake against a flood of new boards, not per-person fairness: costing a stranger's flood is the whole point, and it's cheap to check before anything is written.
- Every other write — a card or column added, moved, edited or
deleted, a board renamed, claimed or undone, and the two writing MCP
tools — is throttled at 300 a minute per caller: a signed-in write is
keyed by forge id, a guest's by the connecting socket address, so one
caller flooding writes can only ever refuse itself, never anyone else on
the same board or any other. The map behind that is capped at 10,000
callers; past it, the least-recently-seen one is evicted and starts
over, which can only hand back a token early, never let anyone exceed
their own rate. Going over either bucket answers 429 with a
retry-after header and recovers on its own; a flood repeating the same
refusal without ever loading a page gets a bare 429, not the whole
board rendered again on every retry.
- Request bodies over 64KB are refused: a declared Content-Length over
the cap is refused before anything is read; a chunked body (or a lying
Content-Length) is caught as bytes are counted while it arrives, with
the read stopped once readBody (src/body.ts) has drained up to
MAX_REQUEST_DRAIN_BYTES rather than the stream's actual end.
These are numbers chosen to comfortably fit real use of one board — some process-wide (undo, columns, cards, board creation), the write limit per caller instead, for the reason above; hitting one always says the number in the error, so it reads as a limit rather than a broken app.
Both buckets live in one process, which is also the one process bun test
runs every test file in, so src/limits.ts exports resetLimits(): every
bucket back to full in one call. Tests that want the limits out of their
way reset before each test, and the one file that wants a limit exhausted
(src/write-limit.test.ts) drains it and resets after. Nothing in the
running app calls it — no test file's result depends on the order bun
happened to run the files in.
It also has a terminal: the exact command is in FORGE_APP_SSH when the app
runs (ssh -p 2222 <name>@<forge> — the app is the login name, the forge is
the host; <name>--<pull>@<forge> for a preview's). It opens src/tty.ts on
a real pty over the same data.
The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV
(production or preview), FORGE_APP_URL, FORGE_APP_COMMIT and
FORGE_APP_IDENTITY_KEY when it starts the app. forge.json says how.
Identity comes only from x-forge-identity / x-forge-signature,
verified against FORGE_APP_IDENTITY_KEY (src/who.ts); unset, everyone
is anonymous. The terminal has no such header; it reads
FORGE_APP_USER_ID and FORGE_APP_USER_ROLE from the environment
instead (src/session.ts), which the forge is expected to set on an ssh
session it has already authenticated — see the sshd caveat under "What's
simulated" — enough to scope which boards the picker lists, not enough
to name someone, so every board the terminal creates is still a guest's,
same as an anonymous browser's.
Dependencies
The app itself has none. happy-dom is a dev-only dependency, used by
src/drag.test.ts, src/touch.test.ts, src/live-refresh.test.ts,
src/theme.test.ts and src/boards.test.ts to give static/board.js
and static/boards.js a real DOM outside a browser so their event
handlers, dragstart/dragover/drop/dragend/EventSource handling,
pointerdown/pointermove/pointerup, and localStorage calls run against
the real thing — hand-rolling that much of the DOM to test a few dozen
lines of script would be more code, and less trustworthy, than the
thing it's testing.