SLOPSHOPPER

trame

A band above the prompt showing the Trame card this session is on, and an exit prompt once that card is done

newbandguardtoastprocessnetwork
★ 2v0.1.0no licenseupdated 2026-10-09Andarius/trame/claude-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · trame
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ○ trame · offline ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
○ trame · offline
README

trame

A local-first Claude Code and Codex session tracker. Each session ladders up to a story (grouped under a project); the board is status columns × swimlanes — the view no off-the-shelf tool gave us, and the columns are yours to define. It also holds free-form pages — inline comments (with agent reply threads), sandboxed interactive HTML blocks, per-page guest sharing, public read-only share links — and Notion-style databases (sortable / filterable / groupable views). ⌘P jumps to any session, page, or database; a card's Resume button reopens the session in Claude Code or Codex. Opt-in plugins add side panels — the first one lists GitHub/GitLab deployments waiting for approval.

Stack: Deno-desktop app → local PGlite (embedded Postgres, offline read+write) → custom changeset LWW sync over HTTPS → a small Deno API in front of Postgres on a home server (the hub), with WebSocket nudges so edits propagate between machines in seconds. No PowerSync, no Electric. Everything is Postgres, so the SQL is identical on the laptop and the hub. (Design + migration story: docs-site/src/content/docs/hub-api.md.)

 laptop A (Deno app)                          laptop B (Deno app)
   ├─ local PGlite  ◀── read/write offline ──▶  local PGlite
   └─ sync ─┐  POST /sync (mutations ⇅ changes)  ┌─ sync
            ├──────────▶  Deno API @ hub  ◀──────┤   (auth boundary; Docker, home LAN)
            └── WSS ◀──  "changed, pull now"  ──▶┘        └─▶ Postgres (source of truth)
 /trame-track ─▶ local app if running, else local outbox.jsonl (app drains on launch)

Demo

Trame walkthrough

A short tour: filter the board by story, regroup into swimlanes, open a session and resume it in Claude Code or Codex, sort the list, then browse a page and a database. (higher-quality MP4 · screens use demo data, recorded pre-0.4)

Kanban board — status columnsSwimlanes — group by project or story
boardgrouped board
Session drawer — resume in Claude Code / CodexSortable list view
drawerlist
Pages — notes & docs next to the workDatabases — Notion-style tables
pagedatabase
Agent presence — who is on which todo, live…and which agent needs you
agent presenceagent presence still
Page activity — every linked agent's worklog, one timelineCard activity — worklog + presence events
page activitycard activity
Reminders — due todos flag late, and gather in the sidebar
reminders

Requirements

  • Deno 2.9+ on each laptop (for deno desktop). Install: curl -fsSL https://deno.land/install.sh | sh.
  • Docker + openssl on the hub machine (certs are generated there; the CA key never leaves it).
  • Laptops reach the hub's API over the home LAN (no Tailscale required; the hub binds to its LAN IP — install Tailscale there if you want sync away from home).
  • Node/npm is pulled in only to build the Vite frontend (via deno task web:build).

Layout

db/schema.sql              shared schema (hub Postgres AND local PGlite) — idempotent; re-applying it IS the migration
docs-site/                 Astro + Starlight docs (data model, hub API design, release notes) — `just docs`
protocol/                  versioned sync protocol shared by app and hub (entities, LWW rule, html-block bridge)
hub/docker-compose.yml     the hub: Postgres (docker-network only) + the Deno API in front of it
hub/api/                   the API: device tokens, changeset /sync, per-page ACLs, WSS nudges, public /l/* pages
hub/deploy.sh              deploy the hub over ssh (~/Apps/trame) — `just db-deploy`
hub/gen-certs.sh           private CA + server certs, runs on the hub (called by deploy)
hub/fetch-ca.sh            fetch the hub's ca.crt so this laptop trusts the API's TLS — `just hub-ca`
hub/pg_hba.conf            Postgres auth rules: local + docker network only, anything else rejected
app/                       Deno-desktop app
  main.ts                  window + in-process HTTP (serves UI + /api), startup sync loop
  db.ts                    local PGlite + queries + outbox drain
  sync-api.ts              changeset push/pull against the hub API (cursor in sync_state)
  realtime.ts              WSS client — hub nudges turn into a pull within seconds
  identity.ts              users/devices — which user this laptop writes as
  config.ts                env config (NODE_ID, data dir…)
  share.ts                 export/import a page subtree as a portable *.trame.json bundle
  csrf.ts                  same-origin guard for /api (it spawns terminals, opens files…)
  plugins/                 opt-in in-tree plugins (deployments: GitHub/GitLab approvals)
  settings-store.ts        single writer for the device-local settings JSON (0600, holds tokens)
  agent-comments.ts        canonical Codex/Claude identities (branded SVG avatars) + block resolution
  presence.ts              ephemeral "who's here" registry (viewers + active watchers; not synced)
  web/                     React swimlane board (Vite)
mcp/server.ts              Trame MCP server (stdio): board, pages, comments, html blocks, reports, sync
track/cli.ts               tramecli — the compiled agent CLI (writers + list/answer/setup/mcp)
track/help.ts              the agent contract strings: CLI --help, MCP capabilities, stub stamping
track/track.ts             the $trame-track session writer (app or outbox)
track/page.ts              the $trame-page writer (Markdown → atomic page create)
track/comment.ts           agent page comments (title/quote resolution + attribution)
track/watch.ts             the comment watcher — agents auto-answer human replies (`tramecli answer`)
track/page-watch.ts        page-scoped poller behind `tramecli watch` — wakes a session on feedback
track/claude-hook.ts       UserPromptSubmit hook: records cwd → Claude session id for track.ts
bin/quickstart.sh          curl-able laptop setup: clone + packaged app + agent integrations
skills/trame-{track,page,watch}/ the agent skills (Claude Code, Codex & friends) — embedded in tramecli, installed by `tramecli setup`

Setup

Quickstart (laptop)

curl -fsSL https://raw.githubusercontent.com/Andarius/trame/master/bin/quickstart.sh | bash

Clones to ~/trame (override with TRAME_DIR), installs Deno if missing, installs the latest packaged app for the platform (snap or AppImage on Linux x64, dmg on Apple Silicon; TRAME_APP=source builds from the checkout instead, TRAME_VERSION=v0.9.0 pins a release), and wires Claude Code and Codex (TRAME_TARGETS=claude or codex to pick one, none to skip; any other agent CLI that reads an Agent Skills directory → TRAME_SKILL_DIRS=~/.gemini/skills). The hub (step 1), device token (step 2), and Claude session hook (step 4) still need the manual steps below.

1. The hub

just db-deploy       # ssh: copies compose+schema+hba+api to ~/Apps/trame, creates .env+certs, starts it
just hub-ca          # per laptop: fetch ca.crt (trusts the API's TLS)

First run generates the password and the CA/server certs, and binds to the hub's LAN IP (never 0.0.0.0). Postgres itself has no host port — laptops talk to the Deno API on :8443, which terminates TLS with the same private-CA cert. Idempotent — rerun to redeploy (re-applies the schema and restarts the API).

2. Each laptop

Mint a device token on the hub (<node-id> = the laptop's TRACKER_NODE_ID), then point the app at the API:

# e.g. for the laptop whose TRACKER_NODE_ID is "mbp-14"
ssh <hub> "docker exec trame-api deno run -A --config /srv/hub/api/deno.json /srv/hub/api/main.ts mint mbp-14"
# → prints the token ONCE (only its sha-256 is stored); re-run mint for a fresh one, revoke old rows in api_tokens

Paste the URL + token in ⚙ Settings → Sync hub, or add to ~/.local/share/trame/settings.json (chmod 600):

{ "hubApi": "https://192.168.1.x:8443", "hubApiToken": "<minted token>" }

Env (shell profile, or the project .env for just):

export TRACKER_NODE_ID="mbp-14"                                   # unique per machine
# folders scanned (depth 4) for *.html reports + *.excalidraw drawings, shown+searchable in Explore
export TRACKER_REPORT_PATHS="$HOME/Projects:$HOME/code"
# optional: client names detected from a repo path (/<Client>/); anything else → "Side-projects"
export TRACKER_CLIENTS="Acme,Globex"

3. Run the app

cd app
deno task web:build     # build the React frontend → web/dist
deno task dev           # opens the desktop window (Deno 2.9+)
# no desktop subcommand yet? →  deno task serve   then open http://localhost:8787
# frontend dev with HMR:        deno task web:dev  (proxies /api to :8787)

4. Wire session tracking

Sessions have their own tags, independent of story and specs-page tags. Use the Tags picker in either the side panel or full-screen session view to add or remove multiple tags, including labels such as priority:P1. Session tags appear on board cards and list rows. The CLI/MCP writer contract below documents tag keys.

Agents talk to Trame through tramecli, one compiled binary that wraps the writers (track, page, comment, watch, list) — its --help carries the full agent contract, including the field-composition conventions:

tramecli --help          # commands overview
tramecli track --help    # the writer contract agents compose against
tramecli list            # open sessions grouped by story (--json for jq)

It ships in the snap (aliased to tramecli by bin/snap-install-release.sh) and as a per-platform release asset. With the binary on PATH, install the agent docs from it — no checkout or deno needed:

tramecli setup                   # interactive target picker
tramecli setup --claude --codex  # non-interactive; also --skills-dir ~/.gemini/skills

From a dev checkout, just setup compiles a fresh binary and runs its setup — flags pass through, and any agent CLI that reads an Agent Skills directory works:

just setup --claude --codex --skills-dir ~/.gemini/skills

In Codex, use $trame-track, $trame-track paused "note", or $trame-track list for sessions, and $trame-page to create or comment on standalone Trame pages. Codex exposes CODEX_THREAD_ID, so the session writer automatically links the card to the current resumable session; no hook is needed.

In Claude Code the same skills land in ~/.claude/skills/: /trame-track records the current session as a card on the board — it reads the repo, branch, and a one-line note from the conversation and writes straight to your local PGlite (syncing to the hub when online, else queued in the outbox). From any repo: /trame-track to log the session, or /trame-track paused|blocked|done "note" to set its status with a note. trame-page is picked up automatically when you ask to save a document, note, or plan as a Trame page, and /trame-watch <page> answers feedback on a page live from the session. (The docs call the bare tramecli; setup links the binary into ~/.local/bin when that name is not already on PATH.)

For the card's Resume button to work, the writer needs the Claude session UUID — skills can't see their own session id, so a UserPromptSubmit hook records it per-cwd into ~/.local/share/trame/claude-sessions.json. Register it in ~/.claude/settings.json (per machine):

"hooks": {
  "UserPromptSubmit": [{ "matcher": "", "hooks": [{
    "type": "command",
    "command": "deno run -A /path/to/trame/track/claude-hook.ts",
    "timeout": 5
  }] }]
}

Without the hook /trame-track still works — the card just has no transcript link. Cards imported from the app's Claude Code + Codex dialog carry the UUID as their id and never need it.

5. Page comments & the agent watcher

Any page block can hold a thread of inline comments. Agents leave review comments with the trame_add_comment MCP tool or tramecli comment (identify the page by title, the block by a unique text quote). agent is the id of the model actually writing — Codex and Claude get a branded avatar, any other model id (glm, gemini, …) gets a generated one — so the author is honest, not forced to a harness seat. Agent comments stay out of your own author identity.

# an agent leaving a comment (JSON as arg or on stdin)
echo '{"page_title":"Release plan","block_text":"Ship the first release",
       "body":"Clarify the rollback criterion.","agent":"codex"}' | tramecli comment

Reply to an agent's comment in the UI and the watcher closes the loop: it marks your reply seen, shows "Claude is answering…", runs the thread's agent to compose an answer, and posts it — with a model · tokens · seconds footer. Run it in its own terminal:

tramecli answer                     # answer any thread whose agent it can run
tramecli answer --cwd ~/Projects/some-repo  # let the agent read that repo when answering (read-only)
tramecli answer --agents claude     # only handle Claude threads
tramecli answer --once --dry-run    # one pass, print prompts without answering

The CLI runs read-only, but the thread text is attacker-controllable on a shared page and is fed to a tool-capable agent: don't point --cwd at a repo holding secrets on shared/multi-user pages — a crafted reply could coax the agent into leaking file contents into its answer.

It finds the running app via the port file, polls every 5s, processes one reply at a time, and survives app restarts (backs off) and its own crashes (a stuck answering… self-heals). Failures retry twice then park as no answer until you edit the reply — never a loop. The agent CLIs must be installed and authenticated in that shell; each answer spends real tokens. codex and claude are built in; any other model (glm, gemini, …) is answerable by giving it a runner via TRAME_WATCH_<AGENT>_CMD — e.g. TRAME_WATCH_GLM_CMD="glm -p {}" (the {} placeholder is replaced by the prompt; no {} → prompt on stdin). The same env var overrides the built-ins, e.g. TRAME_WATCH_CLAUDE_CMD="claude -p {} --output-format json --model haiku".

The top of each page shows a presence stack (Notion-style avatars): you while the page is open, plus every agent a running tramecli answer is covering (copper ring). It's device-local and ephemeral — never synced — so avatars fade ~20s after a tab closes or the watcher stops.

6. Plugins (optional)

⚙ Settings → Manage plugins. Everything ships disabled — a networked plugin never reaches out until you switch it on.

Deployments lists GitHub/GitLab releases waiting for approval in the sidebar (plus in-progress and recently-failed ones), and approves the gate / plays the manual job from the panel. Point it at the repos and projects to watch, then authenticate per forge with a PAT, GITHUB_TOKEN / GITLAB_TOKEN, or the gh / glab CLI (optional — there's a login button that spawns a terminal).

Tokens live only in this machine's settings.json (mode 0600). They are never synced to the hub and never sent back to the UI, and each is bound to the forge host you configured.

Out-of-tree plugins are composed at build time: list their checkouts in a gitignored plugins.local.json ({ "plugins": ["../my-plugin"] }) and run just plugins. A plugin holds mod.ts (backend, imports Trame only via @trame/plugin-api), web/index.ts (a FrontendPlugin, via @trame/web-api) and optional e2e/ specs.

How sync works

Hub and clients must use the same protocol version. Deploy the hub schema and API before restarting updated clients; older clients keep local data but cannot sync until upgraded. Session tags require protocol 6.

  • Transport: HTTPS to the Deno API on :8443 (TLS terminated by the API with the hub's private-CA cert — just hub-ca fetches the CA once per laptop). Every request carries a per-device bearer token, minted on the hub and stored sha-256 at rest, revocable. Postgres itself has no host port — the API is the only way in.
  • Changesets: POST /sync sends local mutations since a cursor and returns {acknowledgements, rejectedMutations, changes, nextCursor}. The cursor is a server-issued monotonic revision (change_log.rev) — client clocks never order delivery. A bad mutation is rejected alone; the rest of the batch lands.
  • LWW merge: every row has updated_at (value clock), origin (writing node), and deleted (soft delete). Hub and laptop apply the same rule from the shared protocol/ package: on conflict … do update … where excluded.updated_at > the stored row's.
  • Authorization: the hub checks every mutation against the caller's access — members see the whole workspace, guests only shared subtrees (grants back-fill history, revocations send tombstones). Comment authorship is pinned server-side.
  • Realtime: triggers append to change_log and pg_notify; the API debounces and nudges connected laptops over WSS ("changed, pull now"), and local writes debounce a push — edits propagate device-to-device in a couple of seconds. The 15s poll stays as fallback.
flowchart LR
    subgraph laptop [Laptop — each machine]
        app[Deno desktop app] --> pgl[(local PGlite<br/>offline read/write)]
        pgl <--> sync[sync-api.ts<br/>changeset push/pull]
        rt[realtime.ts<br/>WSS client]
    end
    subgraph hub [Hub — home server, Docker]
        api[Deno API :8443<br/>device tokens + ACLs] --> pg[(Postgres 18<br/>docker-network only)]
        links[:8444 — public /l/* pages<br/>behind any reverse proxy]
        pg -. LISTEN/NOTIFY .-> api
        pg --> links
    end
    sync <==>|HTTPS, private-CA TLS,<br/>bearer device token| api
    rt <-.->|WSS — nudges only,<br/>data rides /sync| api

Packaging & releases

Push a v* tag (matching app/deno.json version) and GitHub Actions builds the desktop apps and attaches them to the release:

PlatformAssetsFirst launch
LinuxTrame.AppImage, trame.deb, .snap (classic), tramecli-<tag>-linux-x64snap: bin/snap-install-release.sh → snap install --dangerous --classic (aliases trame.tramecli → tramecli)
macOSTrame.dmg (Apple Silicon), tramecli-<tag>-macos-arm64ad-hoc signed — right-click → Open, or xattr -dr com.apple.quarantine /Applications/Trame.app

Proper macOS signing/notarization needs an Apple Developer identity in desktop.macos.codesignIdentity.

Assets (web/dist, db/schema.sql) are embedded into the binary via raw imports (scripts/gen-embed.ts, regenerated by just web-build) — bundles run from anywhere with no disk layout. Local builds: just bundle (AppImage), deno task bundle:mac (on a Mac).

Source 2 files
hooks/register.tsx 134 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Due, Link } from '../types'
5
6const link = atom({ plugin: 'trame', key: 'link' } as const, null)
7const dueAtom = atom({ plugin: 'trame', key: 'due' } as const, { late: 0, soon: 0 })
8const DAY_MS = 86_400_000
9
10// late = before today, soon = today .. +7 days (the sidebar's DUE window)
11export function countDue(dates: string[], today: string): Due {
12  const t = Date.parse(today)
13  const late = dates.filter(d => Date.parse(d) < t).length
14  const soon = dates.filter(d => Date.parse(d) >= t && Date.parse(d) - t <= 7 * DAY_MS).length
15  return { late, soon }
16}
17
18const localDay = (d: Date) =>
19  `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
20const POLL_MS = 15_000
21// tramecli's own reply, so a command that only mentions it does not match
22const DONE = /tracked in Trame \(done /
23const EXIT = 'Exit'
24
25// the app writes its port here on start
26async function appBase($: EngineInterface): Promise<string | null> {
27  const home = await $.env.get('HOME')
28  try {
29    const { port } = JSON.parse(await $.fs.read(`${home}/.local/share/trame/port.json`))
30    return typeof port === 'number' ? `http://127.0.0.1:${port}` : null
31  } catch {
32    return null
33  }
34}
35
36async function refresh($: EngineInterface): Promise<void> {
37  let next: Link = { kind: 'offline' }
38  const base = await appBase($)
39  if (base) {
40    try {
41      const r = await $.http.fetch(`${base}/api/sessions/${await $.session.id()}?events=1`)
42      if (r.ok) {
43        const card = JSON.parse(r.text)
44        next = { kind: 'tracked', url: `${base}/?view=card&card=${card.id}`, title: card.title }
45      }
46      else if (r.status === 404) next = { kind: 'untracked' }
47      const d = await $.http.fetch(`${base}/api/due`)
48      if (d.ok) {
49        const dates = (JSON.parse(d.text) as { due: string }[]).map(x => x.due)
50        const due = countDue(dates, localDay(new Date(await $.clock.now())))
51        await update($, dueAtom, () => due)
52      }
53    } catch {
54      // app gone: stays offline
55    }
56  }
57  await update($, link, () => next)
58}
59
60// xdg-open (Linux), else open (macOS); the clipboard when neither works
61async function openCard($: EngineInterface, url: string, surface: Parameters<EngineInterface['ui']['copy']>[0]['surface']): Promise<void> {
62  for (const cmd of ['xdg-open', 'open']) {
63    try {
64      if ((await $.process.run([cmd, url])).exitCode === 0) return
65    } catch {
66      // not on this OS
67    }
68  }
69  const r = await $.ui.copy({ text: url, surface })
70  $.ui.toast(r.isCopied ? 'Could not open a browser: Trame card link copied' : `Could not open ${url}`)
71}
72
73export const register: Register = on => {
74  let isDone = false
75
76  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
77    const ran = await next(e)
78    if (ran.deny === undefined && !ran.isError && DONE.test(ran.text ?? '')) isDone = true
79    return ran
80  }).catch(($, e, next) => next(e))
81
82  on('session.start', async ($, e, next) => {
83    await refresh($) // localhost: refused or answered in ms
84    $.clock.every(POLL_MS, () => refresh($))
85    return next(e)
86  })
87
88  // tracking usually happens during a turn: show it right away
89  on('turn.complete', async ($, e, next) => {
90    void refresh($)
91    if (isDone && e.agentId === undefined && !e.isAborted) {
92      isDone = false
93      // detached: $.command.run rejects inside a hook the turn waits on
94      void (async () => {
95        const answer = await $.ui.ask('Trame card is done. Exit this session?', [EXIT, 'Stay'])
96        if (answer === EXIT) await $.command.run({ command: 'exit' })
97      })().catch(err => $.ui.log(`trame: exit prompt failed: ${err}`, { to: 'debug' }))
98    }
99    return next(e)
100  })
101
102  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
103    const l = await read($, link)
104    // during a turn the spinner sits above this slot: give it the room
105    if (e.props.hasSurvey || e.props.isWorking || !l) return next(e)
106    const { Box, Button, Text } = $.ui.resolve(e)
107    const due = await read($, dueAtom)
108    const dueText = l.kind === 'offline' || !(due.late + due.soon) ? null : (
109      <Text>
110        {'  '}
111        {due.late ? <Text color="red">⚑ {due.late} late</Text> : <Text color="yellow">⚑</Text>}
112        <Text dimColor>{due.late ? ' · ' : ' '}{due.soon} due</Text>
113      </Text>
114    )
115    if (l.kind !== 'tracked') {
116      return (
117        <Box marginBottom={1} paddingLeft={1}>
118          <Text dimColor>○ trame · {l.kind === 'offline' ? 'offline' : 'no card'}</Text>
119          {dueText}
120        </Box>
121      )
122    }
123    return (
124      <Box marginBottom={1} paddingLeft={1}>
125        <Text color="green">● </Text>
126        <Text>{l.title.length > 48 ? `${l.title.slice(0, 47)}…` : l.title}</Text>
127        <Text> </Text>
128        <Button key="open" label="open" onPress={press => openCard($, l.url, press.surface)} />
129        {dueText}
130      </Box>
131    )
132  })
133}
134
types/index.d.ts 12 lines
1// tracked: the card; untracked: app up, no card; offline: app unreachable
2export type Link = { kind: 'tracked'; url: string; title: string } | { kind: 'untracked' } | { kind: 'offline' }
3
4// todos late, and due within the next 7 days, across all of Trame
5export type Due = { late: number; soon: number }
6
7declare module 'claude-code' {
8  interface PluginState {
9    trame: { link: Link | null; due: Due }
10  }
11}
12