{"slug": "show-hn-open-source-paper", "title": "Show HN: Open-Source Paper", "summary": "Tracepaper, an open-source MCP server that gives AI agents a visual canvas for drawing HTML frames and receiving human feedback via pinned comments, is now available via a one-line install with Bun 1.3+ or Node. The server, created by Caffeineum, runs a single process serving both MCP over stdio and a canvas web app over HTTP, sharing one SQLite database and event bus, with the canvas accessible at http://127.0.0.1:4321 by default. The tool enables a draw-comment-read feedback loop between agents and humans, and supports configuration via environment variables for port, host, and database path.", "body_md": "An MCP server that gives an agent a canvas.\n\nThe agent pushes HTML frames onto it. A human opens the canvas in a browser, scrolls\naround, and drops pinned comments on the frames. The agent reads those comments back\nthrough MCP and replies inside the thread. That round trip — **draw → comment → read\nfeedback** — is the whole product.\n\nOne process serves both halves — an MCP server on stdio and the canvas web app over HTTP,\nsharing one SQLite database and one event bus. You start nothing by hand: your MCP client\nlaunches it, and the canvas comes up with it. An agent's `push_html`\n\nshows up in the human's\nopen browser within seconds, and a human's comment is readable by the agent on the next poll.\n\nOne line, nothing to clone and nothing to build:\n\n```\nclaude mcp add tracepaper -- bunx github:caffeinum/tracepaper\n```\n\nor in any MCP client's JSON:\n\n```\n{ \"mcpServers\": { \"tracepaper\": { \"command\": \"bunx\", \"args\": [\"github:caffeinum/tracepaper\"] } } }\n```\n\nThat is the whole install. `bunx`\n\nresolves the GitHub repo directly — no npm package needed —\nand the canvas bundle compiles itself on first boot.\n\nRequires [Bun](https://bun.sh) 1.3+, since the server uses `bun:sqlite`\n\nand `Bun.serve`\n\n:\n\n```\ncurl -fsSL https://bun.sh/install | bash\n```\n\n`npx -y github:caffeinum/tracepaper`\n\nalso works — the `bin`\n\nis a plain Node launcher that\nexecs Bun, and prints an install line rather than a missing-interpreter error when Bun is\nabsent. But it needs Node *and* Bun to run one Bun program, so prefer `bunx`\n\n.\n\nBothIf you are changing tracepaper, point the client at your clone (`bunx`\n\nand`npx`\n\ncache the fetched repo.`bun run /path/to/tracepaper/src/index.ts`\n\n) — otherwise your agent can sit on a weeks-old copy while you edit, with no sign that it is happening.\n\nFor hacking on it:\n\n```\ngit clone https://github.com/caffeinum/tracepaper.git\ncd tracepaper\nbun install\n```\n\nNo build step to remember — the canvas bundle compiles on first boot if it is missing.\n(`bun run build:web`\n\nexists for up-front builds, and you need it after editing\n`web/canvas.ts`\n\n.)\n\n**One command runs everything.** There is no separate canvas server to start:\n\n```\nbun run src/index.ts          # MCP over stdio AND the canvas over HTTP, one process\n```\n\nThat is what your MCP client launches, and it is the whole setup — the canvas comes up with\nit, at [http://127.0.0.1:4321](http://127.0.0.1:4321) (the resolved URL is also written to `~/.tracepaper/server.json`\n\n,\nand every tool returns it as `canvasUrl`\n\n).\n\n```\nbun run src/index.ts serve    # optional: HTTP only, the canvas with no agent attached\n```\n\n`serve`\n\ndrops the MCP half. It is a convenience for keeping a canvas open in your browser\nacross agent restarts, not a requirement. Running both is safe: a second process started\nagainst the same database detects the live canvas and joins it instead of binding another\nport, so agent pushes land in the tab you already have open.\n\n| env | default | meaning |\n|---|---|---|\n`TRACEPAPER_PORT` |\n`4321` |\nHTTP port. If busy, the next free port is used and reported. |\n`TRACEPAPER_HOST` |\n`127.0.0.1` |\nbind address |\n`TRACEPAPER_DB` |\n`~/.tracepaper/tracepaper.db` |\nSQLite file. `:memory:` for throwaway runs. |\n\nstdout belongs to the MCP stdio transport; every log line goes to stderr.\n\nOne canvas per machine, by default.`~/.tracepaper/tracepaper.db`\n\nand port 4321 are global, so two projects both wired with the config below share one canvas — project B's agent will call`list_frames`\n\nand get project A's frames, and`push_html`\n\nwith no`frameId`\n\nwill land its work next to them. To give a project its own, set`TRACEPAPER_DB`\n\nand`TRACEPAPER_PORT`\n\nin that project's MCP config.\n\nThe one-liner above works in every MCP client — Claude Code, Cursor, Windsurf, Zed, Claude\nDesktop — via `.mcp.json`\n\n, `~/.cursor/mcp.json`\n\nor `claude_desktop_config.json`\n\n:\n\n```\n{ \"mcpServers\": { \"tracepaper\": { \"command\": \"npx\", \"args\": [\"-y\", \"github:caffeinum/tracepaper\"] } } }\n```\n\nRunning from a clone instead? Point at the entry file, with an absolute path — the client sets its own working directory:\n\n```\n{\n  \"mcpServers\": {\n    \"tracepaper\": {\n      \"command\": \"bun\",\n      \"args\": [\"run\", \"/absolute/path/to/tracepaper/src/index.ts\"],\n      \"env\": {\n        \"TRACEPAPER_PORT\": \"4321\"\n      }\n    }\n  }\n}\n```\n\nVerify it end to end without restarting your agent, using the\n[ mcpt CLI](https://github.com/f/mcp-tools) (\n\n`brew install f/mcptools/mcp`\n\n):\n\n```\nmcpt tools bun run src/index.ts\nmcpt call push_html --params '{\"html\":\"<h1>hello</h1>\",\"name\":\"Smoke test\"}' bun run src/index.ts\n```\n\nThe second prints a `canvasUrl`\n\n— open it and the frame is there. That frame is real and stays\non your canvas: delete it from the pin menu, or\n`mcpt call delete_frame --params '{\"frameId\":\"frm_…\"}' bun run src/index.ts`\n\n.\n\nClick **Share** in the toolbar. tracepaper runs [ cloudflared](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/)\nfor you and hands back a public URL anyone can open — no account, no config, no DNS:\n\n```\nhttps://trustee-borough-contemporary-molecular.trycloudflare.com\n```\n\nWhile a share is live it becomes the canvas's address: every tool returns it as `canvasUrl`\n\n,\nso the agent hands out a link that works for someone who is not at your machine. Stop sharing\nand it reverts to localhost. Requires `cloudflared`\n\non PATH (`brew install cloudflared`\n\n); the\npanel says so if it is missing.\n\n**Linking to one frame.** Select a frame and the address bar becomes\n`…/#frame=frm_abc123`\n\n. Send that and it opens zoomed to that frame. It is a **view hint, not a\npermission boundary** — the whole canvas is still there to scroll to, and anyone with the link\nsees all of it either way.\n\nRead the warnings in the panel — they are real:\n\n**anyone with the link can view every frame and post comments.** There is no sign-in, and a comment is read by your agent as feedback. Only share where you would share a screen.**the link dies with the server**, and each share mints a new one. It is not a stable address.** updates reach visitors within a few seconds, not instantly**— a quick tunnel does not carry the SSE stream, so the canvas falls back to its periodic refetch. Measured: 0–5s.\n\n| tool | what it does |\n|---|---|\n`push_html` |\n`{html, name?, frameId?, width?, height?, x?, y?}` — draws a frame. No `frameId` creates one, auto-placed beside the last and wrapping onto a new row so the canvas stays readable; pass `x` /`y` (world px) to place it yourself and group related work; with `frameId` it replaces that frame's HTML in place and bumps `version` , resizing it too if you pass `width` /`height` . An unknown `frameId` is an error, never a silent create. Returns `{frameId, name, version, url, canvasUrl}` . |\n`get_comments` |\n`{frameId?, since?, includeResolved?, author?}` — reads the human's feedback oldest-first, resolved excluded by default. Returns `{comments, cursor, frames}` ; pass `cursor` back as `since` to poll for only what is new. Poll with `author: \"human\"` or your own replies come back looking like fresh feedback. `since` accepts an ISO timestamp too, but that matches only comments created after it — one the human edited or re-opened never comes back, and two written in the same millisecond cannot be separated. Prefer the cursor. |\n`get_frame` |\n`{frameId}` → the frame's current HTML, name, size and version. Call it before `push_html` on a frame you did not author this session: `push_html` replaces the whole document, so pushing blind discards whatever is there. |\n`list_frames` |\n`{}` → every frame with size, position, version, `commentCount` , `unresolvedCount` (no HTML), plus `canvasUrl` . |\n`reply_to_comment` |\n`{commentId, text}` — posts a threaded reply as `\"agent\"` ; it appears live in the human's open thread. |\n`resolve_comment` |\n`{commentId, note?}` — closes the thread so it drops out of `get_comments` ; `note` is also posted as an agent reply. Replies are resolved with their root, so your own note does not come back as fresh feedback on the next poll. |\n`tidy_canvas` |\n`{}` — re-packs every frame into clean rows, largest first, so nothing overlaps. Moves frames only; html, comments and pins are untouched. |\n`delete_frame` |\n`{frameId}` — removes a frame and its comments. |\n\n```\nagent                                     human\n-----                                     -----\npush_html { html: \"<h1>Pricing</h1>…\" }\n  → { frameId: \"frm_a1b2c3d4e5f6\",\n      canvasUrl: \"http://127.0.0.1:4321/\" }\n\"open the canvas and tell me what's off\"\n                                          opens canvasUrl, presses `c`,\n                                          clicks the frame, types\n                                          \"the CTA is buried\"\n\nget_comments { frameId: \"frm_a1b2c3d4e5f6\" }\n  → { comments: [{ id: \"cmt_9f8e…\",\n                   x: 612, y: 340,\n                   text: \"the CTA is buried\",\n                   author: \"human\" }],\n      cursor: \"cur_7\" }\n\nreply_to_comment { commentId: \"cmt_9f8e…\",\n                   text: \"moving it above the fold\" }\n                                          sees the reply in the thread\n\npush_html { frameId: \"frm_a1b2c3d4e5f6\",\n            html: \"<h1>Pricing</h1>…\" }   the iframe reloads in place,\n  → { version: 2 }                        pan/zoom and pins survive\n\nresolve_comment { commentId: \"cmt_9f8e…\",\n                  note: \"CTA is now first\" }\n                                          the pin greys out\n\nget_comments { since: \"cur_7\" }           ← poll with the cursor for what's new\n```\n\nComments are anchored to the frame, not to the DOM, so they survive every `push_html`\n\nupdate. Each comment records the `frameVersion`\n\nit was left on.\n\n- Two-finger scroll pans,\n`⌘`\n\n/`ctrl`\n\n+ scroll zooms around the cursor, space-drag or middle-drag pans,`⌘0`\n\nresets,`⌘1`\n\nzooms to fit. `c`\n\n(or the toolbar button) arms comment mode: the next click on a frame drops a pin at that frame-local coordinate and opens a composer.`esc`\n\ncancels.- Click a frame to select it (its label and outline pick up the accent).\n`f`\n\nor`⌘0`\n\nfits the selection to the viewport;`⌘1`\n\nfits everything. - Drag a frame's\n**title** to move it. The title is the handle, so dragging never fights with clicking into the page itself. The move is written once on release. - Click a pin to open its thread — reply, resolve, or delete there.\n- Frames land in rows, wrapping about three wide, rather than marching off to the right forever.\nPlacement checks every existing frame, so a new frame never lands on one — including after a\nresize or an explicit\n`x`\n\n/`y`\n\n.`tidy_canvas`\n\nre-packs a canvas that is already tangled. - Sound inside a frame works once you interact with it. Autoplay on load is blocked by the browser's policy for sandboxed frames, which is the behaviour you want — a canvas full of frames cannot start making noise on its own.\n- Double-click a frame to interact with the page inside it; entering also fits it to the\nviewport, since that is what you want when you step in to use it.\n`esc`\n\nleaves — including while your cursor is in a field inside the frame, which needs a small script served with each frame, because a cross-origin sandbox otherwise swallows the keypress. Clicking the canvas or the pill under the frame also leaves. - Chrome floats over a full-bleed canvas; nothing holds a permanent column.\n`t`\n\n(or the toolbar's**List**) opens the comment list on the right, grouped by frame with unresolved first — clicking an entry pans to its pin. It stays closed until you ask for it; the badge on the toolbar button is what tells you feedback is waiting. - Frames render in\n`<iframe sandbox=\"allow-scripts allow-forms allow-popups\">`\n\nwith no`allow-same-origin`\n\n, so pushed HTML cannot reach the canvas app or its storage. - SSE keeps it live: pushes, comments, replies, and resolutions all arrive without a reload.\n\n| method | path | purpose |\n|---|---|---|\n| GET | `/` |\ncanvas app |\n| GET | `/api/frames` |\n`{frames}` — frame list, no HTML |\n| GET | `/api/frames/:id` |\nframe incl. HTML |\n| POST | `/api/frames` |\ncreate (201) / update in place (200) — same body as `push_html` |\n| DELETE | `/api/frames/:id` |\n`{ok, id}` |\n| GET | `/f/:id` |\nraw frame HTML for the iframe `src` |\n| GET | `/api/comments?frameId=&since=&includeResolved=&author=` |\n`{comments, cursor}` |\n| POST | `/api/comments` |\n`{frameId, x, y, text, parentId?, author?}` → 201 |\n| PATCH | `/api/comments/:id` |\n`{resolved?, text?}` |\n| DELETE | `/api/comments/:id` |\n`{ok, id}` |\n| GET | `/api/events` |\nSSE: `frame.created` `frame.updated` `frame.deleted` `comment.created` `comment.updated` `comment.deleted` |\n| GET | `/api/health` |\n`{ok: true, frames, comments}` |\n\nErrors are `{error: string}`\n\nwith a real status code. Bad input fails with the zod\nmessage — never a coerced default.\n\nTwo commands. The first is the whole suite; the second proves the same loop against a client that is not our code at all.\n\n```\nbun run typecheck      # tsc --noEmit, strict\nbun run build:web      # web/dist/canvas.js — the server refuses to boot without it\nbun test               # store units + HTTP/SSE integration + MCP e2e over a real client\nbash test/mcpt-loop.sh # the same loop driven by the external `mcpt` CLI\n```\n\nExpect `bun test`\n\nto report **90 pass / 0 fail across 3 files** in ~15s, and the script to\nend with `OK — the loop works through mcpt`\n\n. Both exit non-zero on any failure, and both\nare safe to run repeatedly: every test gets its own temp database, its own `HOME`\n\n(so your\nreal `~/.tracepaper/server.json`\n\nis never touched), and an ephemeral port. Nothing needs\ncleaning up between runs.\n\n| file | what it covers |\n|---|---|\n`test/store.test.ts` |\nids, cascades, cursor durability (a cursor must survive resolving, editing, re-opening and deleting what it covered), transactional writes, size caps |\n`test/http.test.ts` |\nevery route on an ephemeral port, plus SSE (one test idles 12s on purpose, to prove a stream outlives Bun's 10s default `idleTimeout` ) |\n`test/mcp-e2e.test.ts` |\nthe MCP surface, from outside |\n\n`test/mcp-e2e.test.ts`\n\nnever calls a handler directly. Each test spawns\n`bun src/index.ts`\n\nas a child process against a fresh temp-file database, connects the\nSDK's `Client`\n\nover `StdioClientTransport`\n\n, and drives the real loop: all six tools are\nadvertised → `get_frame`\n\nreads a frame's HTML back before an update replaces it → `push_html`\n\ncreates a frame whose `canvasUrl`\n\nand `/f/:id`\n\nare both live →\npushing the same `frameId`\n\nbumps the version without adding a frame → a bogus `frameId`\n\nis a tool error that creates nothing → a comment POSTed over HTTP the way the browser\ndoes comes back through `get_comments`\n\n→ the returned cursor yields only what is new →\n`reply_to_comment`\n\nlands in the thread → `resolve_comment`\n\nresolves the root *and* posts\nits note as an agent reply → `delete_frame`\n\ncascades. It also asserts the process\ncontract: the child exits on stdin EOF alone with no signal, and two server processes\ncan share one database file without losing writes.\n\nNeeds the [ mcpt CLI](https://github.com/f/mcp-tools) on\n\n`PATH`\n\n, and exits 127 with an\ninstall hint if it is missing (`brew install f/mcptools/mcp`\n\n). It is not part of\n`bun test`\n\nfor that reason — run it before you ship.\n\n```\nbash test/mcpt-loop.sh\n```\n\nIt stands up one long-lived `serve`\n\nprocess on a pinned port as the human's browser side,\nthen has `mcpt`\n\nspawn a fresh stdio server per call against the **same** temp database\nfile — so the handoff it proves is a real cross-process one. Note that `mcpt`\n\nexits 0 even\nwhen a tool returns `isError`\n\n, so the script checks `isError`\n\nitself on every call rather\nthan trusting the exit code.", "url": "https://wpnews.pro/news/show-hn-open-source-paper", "canonical_source": "https://github.com/caffeinum/tracepaper", "published_at": "2026-08-14 21:38:54+00:00", "updated_at": "2026-08-14 22:26:35.486492+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools"], "entities": ["Tracepaper", "Caffeineum", "Bun", "Node", "SQLite", "MCP", "Claude Code", "Cursor"], "alternates": {"html": "https://wpnews.pro/news/show-hn-open-source-paper", "markdown": "https://wpnews.pro/news/show-hn-open-source-paper.md", "text": "https://wpnews.pro/news/show-hn-open-source-paper.txt", "jsonld": "https://wpnews.pro/news/show-hn-open-source-paper.jsonld"}}