cd /news/ai-agents/show-hn-open-source-paper ยท home โ€บ topics โ€บ ai-agents โ€บ article
[ARTICLE ยท art-97392] src=github.com โ†— pub= topic=ai-agents verified=true sentiment=ยท neutral

Show HN: Open-Source Paper

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.

read12 min views1 publishedAug 14, 2026
Show HN: Open-Source Paper
Image: Michielbdejong (auto-discovered)

An MCP server that gives an agent a canvas.

The agent pushes HTML frames onto it. A human opens the canvas in a browser, scrolls around, and drops pinned comments on the frames. The agent reads those comments back through MCP and replies inside the thread. That round trip โ€” draw โ†’ comment โ†’ read feedback โ€” is the whole product.

One process serves both halves โ€” an MCP server on stdio and the canvas web app over HTTP, sharing one SQLite database and one event bus. You start nothing by hand: your MCP client launches it, and the canvas comes up with it. An agent's push_html

shows up in the human's open browser within seconds, and a human's comment is readable by the agent on the next poll.

One line, nothing to clone and nothing to build:

claude mcp add tracepaper -- bunx github:caffeinum/tracepaper

or in any MCP client's JSON:

{ "mcpServers": { "tracepaper": { "command": "bunx", "args": ["github:caffeinum/tracepaper"] } } }

That is the whole install. bunx

resolves the GitHub repo directly โ€” no npm package needed โ€” and the canvas bundle compiles itself on first boot.

Requires Bun 1.3+, since the server uses bun:sqlite

and Bun.serve

:

curl -fsSL https://bun.sh/install | bash

npx -y github:caffeinum/tracepaper

also works โ€” the bin

is a plain Node launcher that execs Bun, and prints an install line rather than a missing-interpreter error when Bun is absent. But it needs Node and Bun to run one Bun program, so prefer bunx

.

BothIf you are changing tracepaper, point the client at your clone (bunx

andnpx

cache the fetched repo.bun run /path/to/tracepaper/src/index.ts

) โ€” otherwise your agent can sit on a weeks-old copy while you edit, with no sign that it is happening.

For hacking on it:

git clone https://github.com/caffeinum/tracepaper.git
cd tracepaper
bun install

No build step to remember โ€” the canvas bundle compiles on first boot if it is missing. (bun run build:web

exists for up-front builds, and you need it after editing web/canvas.ts

.)

One command runs everything. There is no separate canvas server to start:

bun run src/index.ts          # MCP over stdio AND the canvas over HTTP, one process

That is what your MCP client launches, and it is the whole setup โ€” the canvas comes up with it, at http://127.0.0.1:4321 (the resolved URL is also written to ~/.tracepaper/server.json

, and every tool returns it as canvasUrl

).

bun run src/index.ts serve    # optional: HTTP only, the canvas with no agent attached

serve

drops the MCP half. It is a convenience for keeping a canvas open in your browser across agent restarts, not a requirement. Running both is safe: a second process started against the same database detects the live canvas and joins it instead of binding another port, so agent pushes land in the tab you already have open.

env default meaning
TRACEPAPER_PORT
4321
HTTP port. If busy, the next free port is used and reported.
TRACEPAPER_HOST
127.0.0.1
bind address
TRACEPAPER_DB
~/.tracepaper/tracepaper.db
SQLite file. :memory: for throwaway runs.

stdout belongs to the MCP stdio transport; every log line goes to stderr.

One canvas per machine, by default.~/.tracepaper/tracepaper.db

and port 4321 are global, so two projects both wired with the config below share one canvas โ€” project B's agent will calllist_frames

and get project A's frames, andpush_html

with noframeId

will land its work next to them. To give a project its own, setTRACEPAPER_DB

andTRACEPAPER_PORT

in that project's MCP config.

The one-liner above works in every MCP client โ€” Claude Code, Cursor, Windsurf, Zed, Claude Desktop โ€” via .mcp.json

, ~/.cursor/mcp.json

or claude_desktop_config.json

:

{ "mcpServers": { "tracepaper": { "command": "npx", "args": ["-y", "github:caffeinum/tracepaper"] } } }

Running from a clone instead? Point at the entry file, with an absolute path โ€” the client sets its own working directory:

{
  "mcpServers": {
    "tracepaper": {
      "command": "bun",
      "args": ["run", "/absolute/path/to/tracepaper/src/index.ts"],
      "env": {
        "TRACEPAPER_PORT": "4321"
      }
    }
  }
}

Verify it end to end without restarting your agent, using the mcpt CLI (

brew install f/mcptools/mcp

):

mcpt tools bun run src/index.ts
mcpt call push_html --params '{"html":"<h1>hello</h1>","name":"Smoke test"}' bun run src/index.ts

The second prints a canvasUrl

โ€” open it and the frame is there. That frame is real and stays on your canvas: delete it from the pin menu, or mcpt call delete_frame --params '{"frameId":"frm_โ€ฆ"}' bun run src/index.ts

.

Click Share in the toolbar. tracepaper runs cloudflared for you and hands back a public URL anyone can open โ€” no account, no config, no DNS:

https://trustee-borough-contemporary-molecular.trycloudflare.com

While a share is live it becomes the canvas's address: every tool returns it as canvasUrl

, so the agent hands out a link that works for someone who is not at your machine. Stop sharing and it reverts to localhost. Requires cloudflared

on PATH (brew install cloudflared

); the panel says so if it is missing.

Linking to one frame. Select a frame and the address bar becomes โ€ฆ/#frame=frm_abc123

. Send that and it opens zoomed to that frame. It is a view hint, not a permission boundary โ€” the whole canvas is still there to scroll to, and anyone with the link sees all of it either way.

Read the warnings in the panel โ€” they are real:

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.

tool what it does
push_html
{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} .
get_comments
{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.
get_frame
{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.
list_frames
{} โ†’ every frame with size, position, version, commentCount , unresolvedCount (no HTML), plus canvasUrl .
reply_to_comment
{commentId, text} โ€” posts a threaded reply as "agent" ; it appears live in the human's open thread.
resolve_comment
{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.
tidy_canvas
{} โ€” re-packs every frame into clean rows, largest first, so nothing overlaps. Moves frames only; html, comments and pins are untouched.
delete_frame
{frameId} โ€” removes a frame and its comments.
agent                                     human
-----                                     -----
push_html { html: "<h1>Pricing</h1>โ€ฆ" }
  โ†’ { frameId: "frm_a1b2c3d4e5f6",
      canvasUrl: "http://127.0.0.1:4321/" }
"open the canvas and tell me what's off"
                                          opens canvasUrl, presses `c`,
                                          clicks the frame, types
                                          "the CTA is buried"

get_comments { frameId: "frm_a1b2c3d4e5f6" }
  โ†’ { comments: [{ id: "cmt_9f8eโ€ฆ",
                   x: 612, y: 340,
                   text: "the CTA is buried",
                   author: "human" }],
      cursor: "cur_7" }

reply_to_comment { commentId: "cmt_9f8eโ€ฆ",
                   text: "moving it above the fold" }
                                          sees the reply in the thread

push_html { frameId: "frm_a1b2c3d4e5f6",
            html: "<h1>Pricing</h1>โ€ฆ" }   the iframe reloads in place,
  โ†’ { version: 2 }                        pan/zoom and pins survive

resolve_comment { commentId: "cmt_9f8eโ€ฆ",
                  note: "CTA is now first" }
                                          the pin greys out

get_comments { since: "cur_7" }           โ† poll with the cursor for what's new

Comments are anchored to the frame, not to the DOM, so they survive every push_html

update. Each comment records the frameVersion

it was left on.

  • Two-finger scroll pans, โŒ˜

/ctrl

  • scroll zooms around the cursor, space-drag or middle-drag pans,โŒ˜0

resets,โŒ˜1

zooms to fit. c

(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

cancels.- Click a frame to select it (its label and outline pick up the accent). f

orโŒ˜0

fits the selection to the viewport;โŒ˜1

fits everything. - Drag a frame's 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.

  • Frames land in rows, wrapping about three wide, rather than marching off to the right forever. Placement checks every existing frame, so a new frame never lands on one โ€” including after a resize or an explicit x

/y

.tidy_canvas

re-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.

  • Double-click a frame to interact with the page inside it; entering also fits it to the viewport, since that is what you want when you step in to use it. esc

leaves โ€” 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. t

(or the toolbar'sList) 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 <iframe sandbox="allow-scripts allow-forms allow-popups">

with noallow-same-origin

, 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.

method path purpose
GET /
canvas app
GET /api/frames
{frames} โ€” frame list, no HTML
GET /api/frames/:id
frame incl. HTML
POST /api/frames
create (201) / update in place (200) โ€” same body as push_html
DELETE /api/frames/:id
{ok, id}
GET /f/:id
raw frame HTML for the iframe src
GET /api/comments?frameId=&since=&includeResolved=&author=
{comments, cursor}
POST /api/comments
{frameId, x, y, text, parentId?, author?} โ†’ 201
PATCH /api/comments/:id
{resolved?, text?}
DELETE /api/comments/:id
{ok, id}
GET /api/events
SSE: frame.created frame.updated frame.deleted comment.created comment.updated comment.deleted
GET /api/health
{ok: true, frames, comments}

Errors are {error: string}

with a real status code. Bad input fails with the zod message โ€” never a coerced default.

Two commands. The first is the whole suite; the second proves the same loop against a client that is not our code at all.

bun run typecheck      # tsc --noEmit, strict
bun run build:web      # web/dist/canvas.js โ€” the server refuses to boot without it
bun test               # store units + HTTP/SSE integration + MCP e2e over a real client
bash test/mcpt-loop.sh # the same loop driven by the external `mcpt` CLI

Expect bun test

to report 90 pass / 0 fail across 3 files in ~15s, and the script to end with OK โ€” the loop works through mcpt

. Both exit non-zero on any failure, and both are safe to run repeatedly: every test gets its own temp database, its own HOME

(so your real ~/.tracepaper/server.json

is never touched), and an ephemeral port. Nothing needs cleaning up between runs.

file what it covers
test/store.test.ts
ids, cascades, cursor durability (a cursor must survive resolving, editing, re-opening and deleting what it covered), transactional writes, size caps
test/http.test.ts
every route on an ephemeral port, plus SSE (one test idles 12s on purpose, to prove a stream outlives Bun's 10s default idleTimeout )
test/mcp-e2e.test.ts
the MCP surface, from outside

test/mcp-e2e.test.ts

never calls a handler directly. Each test spawns bun src/index.ts

as a child process against a fresh temp-file database, connects the SDK's Client

over StdioClientTransport

, and drives the real loop: all six tools are advertised โ†’ get_frame

reads a frame's HTML back before an update replaces it โ†’ push_html

creates a frame whose canvasUrl

and /f/:id

are both live โ†’ pushing the same frameId

bumps the version without adding a frame โ†’ a bogus frameId

is a tool error that creates nothing โ†’ a comment POSTed over HTTP the way the browser does comes back through get_comments

โ†’ the returned cursor yields only what is new โ†’ reply_to_comment

lands in the thread โ†’ resolve_comment

resolves the root and posts its note as an agent reply โ†’ delete_frame

cascades. It also asserts the process contract: the child exits on stdin EOF alone with no signal, and two server processes can share one database file without losing writes.

Needs the mcpt CLI on

PATH

, and exits 127 with an install hint if it is missing (brew install f/mcptools/mcp

). It is not part of bun test

for that reason โ€” run it before you ship.

bash test/mcpt-loop.sh

It stands up one long-lived serve

process on a pinned port as the human's browser side, then has mcpt

spawn a fresh stdio server per call against the same temp database file โ€” so the handoff it proves is a real cross-process one. Note that mcpt

exits 0 even when a tool returns isError

, so the script checks isError

itself on every call rather than trusting the exit code.

โ”€โ”€ more in #ai-agents 4 stories ยท sorted by recency
โ”€โ”€ more on @tracepaper 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain โ€” perfect for shipping the agent you just read about.

$git push zahid main
โ†’ Live at https://your-agent.zahid.host โœ“
Get free account โ†’ Pricing
from โ‚ฌ0/mo ยท no card required
LIVE [news/show-hn-open-source-โ€ฆ] indexed:0 read:12min 2026-08-14 ยท โ€”