A tiny self-hosted server for storing and browsing self-contained HTML "plans". AI agents push plans as HTML via an authenticated REST API; humans browse them in a React UI. Designed to run on a headless box reachable over a Tailnet.
Each plan is a single .html file stored on disk, plus an index.json catalog.
The index is rebuilt from the .html files on startup, so deleting index.json
is non-destructive.
- Bun (runtime + test runner)
cp .env.example .env # then edit .env and set VISUALPLANNER_TOKEN
bun install
bun run dev # API server on :7070 (watches for changes)
bun run dev:ui # Vite UI on :5173 (proxies /api -> :7070)Open http://localhost:5173 in development.
When agents publish plans during development, set VISUALPLANNER_VIEWER_URL to the
browser-facing Vite URL (for example, http://localhost:5173) while keeping
VISUALPLANNER_URL pointed at the API (http://localhost:7070). The publishing
skill uses the viewer URL for returned plan links and falls back to the API URL in
production.
For production (builds the UI, then serves it from the API origin on :7070):
bun run startDuring normal agent work in a worktree, run targeted tests covering the changed area. The unscoped test suite is for CI or an explicitly requested cross-workspace check; do not start it independently from multiple worktrees.
| Script | Description |
|---|---|
bun run dev |
Run the API server with --watch (port 7070). |
bun run dev:ui |
Run the Vite dev server for the UI (port 5173, proxies /api). |
bun run build |
Build the UI into dist/client/. |
bun run start |
Build, then run the production server (serves UI + API on :7070). |
bun run test -- path/to/test.test.ts |
Run targeted tests (bun:test). |
bun run typecheck |
Type-check with tsc --noEmit. |
bun run lint |
Lint with Biome. |
bun run format |
Format the codebase in place with Biome. |
bun run check |
Apply Biome's safe lint + format fixes in place. |
All settings are optional environment variables (read from .env in the working
directory, with real process.env taking precedence):
| Variable | Default | Meaning |
|---|---|---|
VISUALPLANNER_TOKEN |
change-me (disabled) |
Bearer token agents must present to write. Unset/change-me = no auth. |
VISUALPLANNER_PORT |
7070 |
Port to listen on. |
VISUALPLANNER_DATA_DIR |
~/.local/share/visualplanner |
Where plan .html files and index.json are stored. ~ is expanded. |
VISUALPLANNER_BIND |
auto (Tailscale IP, else 127.0.0.1) |
Hostname to bind. Empty/unset auto-detects a Tailscale 100.x address. |
VISUALPLANNER_PROJECT_ALIASES |
(none) | Comma-separated old:new project name aliases, e.g. HI0890-Zephyr:Zephyr,org/old:New. Normalizes project names on intake and index reconcile. |
- Content writes (
POST/PUT/DELETEon plans) require the token, sent as eitherAuthorization: Bearer <token>orX-VisualPlanner-Token: <token>. - Reads (
GET) and reader actions (PUT /api/plans/:id/read) are always unauthenticated, even when a token is set. - The SPA asks for the token once (a "Token required" dialog the first time
you delete a plan), stores it in
localStorage, and sends it asX-VisualPlanner-Tokenon content writes — the same token agents use. - If
VISUALPLANNER_TOKENis unset or literallychange-me, auth is disabled entirely. The server then refuses to bind beyond localhost — if no token is set and the resolved bind address is not loopback, startup aborts with an error. Set a real token to listen on a Tailscale interface. - The API is same-origin only (no CORS headers). The SPA is served from the same origin, so it works unchanged; other websites cannot read plans cross-origin. Reads are still unauthenticated, so anyone who can reach the host directly can read plans — keep sensitive content out of them.
Generate a token with:
openssl rand -hex 32| Method | Path | Auth | Description |
|---|---|---|---|
GET |
/api/plans |
none | List plan metadata, newest-first. |
GET |
/api/plans/:id |
none | Get one plan's metadata. |
GET |
/api/plans/:id/html |
none | Get a plan's raw HTML (text/html). |
POST |
/api/plans |
token | Create a plan. Returns 201 { plan }. |
PUT |
/api/plans/:id |
token | Update a plan in place (id stable). |
DELETE |
/api/plans/:id |
token | Delete a plan. Returns { ok: true }. |
PUT |
/api/plans/:id/read |
none | Mark a plan read/unread. Body: { read: boolean }. |
GET |
/api/events |
none | SSE stream: created/updated/deleted events. |
GET |
/api/search?q= |
none | Full-text search inside plan HTML. Returns { results: [{ id, snippet }] }. |
PUT accepts any subset of the above.
template controls the plan's document contract while type and scope remain
independent metadata facets. If omitted, the server derives a compatible
template from the existing type and scope values, so older agents and
catalogs continue to work.
The plan id is derived as slugify(project)-slugify(title), with a -N
suffix appended on collision (e.g. my-project-refactor-the-foo-2).
curl -X POST http://localhost:7070/api/plans \
-H "Authorization: Bearer $VISUALPLANNER_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"project\":\"demo\",\"title\":\"hello\",\"html\":\"<h1>Hi</h1>\"}"src/
client/ Vite + React SPA (editorial "desk at night" feed UI)
components/ Vendored shadcn ui/ primitives (used by the token prompt)
server/ Hono + Bun API, disk-backed Store, SSE events, config, auth
shared/ Types shared between client and server
skill/ Agent authoring skill + Editorial house style and templates
The skill/ directory holds the publishing skill that agents use to generate
plans in the VisualPlanner Editorial house style, plus plan.css,
gallery.html, and six content contracts in skill/templates/.
{ "project": "my-project", // bare repo name; default "untitled" "title": "Refactor the foo", // default "Untitled plan" "sessionId": "abc123", // optional "type": "refactor", // refactor|feature|bugfix|architecture|research; default "architecture" "scope": "frontend", // backend|frontend|infra|tooling|docs; default "backend" "template": "refactor-migration", // ui-change|architecture-decision|backend-contract|bug-diagnosis|refactor-migration|research-brief "html": "<!doctype html>..." // required, non-empty (create only) }