pageshelf

Your agent writes a report, a plan, or a dashboard. pageshelf hosts it. You upload the page with one HTTP PUT and get back a URL. Every page gets the same stylesheet, so if you edit one CSS file, every page you have ever uploaded changes. You run it on your own machine. There is no database.

How it works

It is one Hono app that runs in two places. The main copy runs on your machine with Bun and saves pages as files on disk. You can also deploy it as a Cloudflare Worker that saves the same pages in R2, so you can still read them when you are away from your machine.

Your machine is always the source of truth. The only thing that writes to Cloudflare is the mirror, so the two copies cannot disagree. If the mirror fails, your upload still works. The failed request goes into pending.json and retries every five minutes. If you upload the same page again, the newer write replaces the queued one.

The styling happens when a page is served, not when you upload it. pageshelf adds a stylesheet link and a small header script to the page's <head> on the way out. It does not change the file on disk. That is why editing styles/site.css updates every page at once and you never re-upload anything. If a page already has its own <nav>, pageshelf leaves it alone.

Install

You need Bun. There are three scripts, and it is safe to run them more than once.

git clone https://github.com/hdprajwal/pageshelf
cd pageshelf

deploy/setup.sh     # installs deps, writes a config with a new token
deploy/install.sh   # sets up a systemd user service and starts it
deploy/update.sh    # pull, test, restart. Does nothing if already current

That gives you http://pageshelf.local:8788, listening only on your own machine. install.sh tells you what it wants to change outside the repo and asks before it does anything. On the default port that is one line added to /etc/hosts and nothing else. You can skip steps with PAGESHELF_SKIP_HOSTS=1 or PAGESHELF_SKIP_LINGER=1.

Or skip the scripts and just run it:

bun install
PAGESHELF_TOKEN=$(openssl rand -hex 32) bun run src/local.ts

Uploading pages

Writes need a token. Reads do not need anything.

# HTML. Stored and served as you wrote it.
curl -X PUT http://pageshelf.local:8788/pages/my-report \
  -H "Authorization: Bearer $PAGESHELF_TOKEN" \
  -H "Content-Type: text/html" \
  --data-binary @report.html
# -> {"slug":"my-report","urls":{"local":"...","tailnet":"...","public":"..."}}

# Markdown. Rendered into the shared theme when it is served.
curl -X PUT http://pageshelf.local:8788/pages/my-notes \
  -H "Authorization: Bearer $PAGESHELF_TOKEN" \
  -H "Content-Type: text/markdown" \
  --data-binary @notes.md

# Filed under a project, so a later session can find it again.
curl -X PUT "http://pageshelf.local:8788/pages/hero-variant?project=example-project&tags=variant,draft" \
  -H "Authorization: Bearer $PAGESHELF_TOKEN" \
  -H "Content-Type: text/html" \
  --data-binary @hero.html

# Shared files. Use them from any page as /assets/logo.png
curl -X PUT http://pageshelf.local:8788/assets/logo.png \
  -H "Authorization: Bearer $PAGESHELF_TOKEN" \
  --data-binary @logo.png

A write returns the URLs where the page is now available. Which ones you get back depends on what you have set up. Markdown titles come from the first heading. HTML titles come from the <title> tag. Slugs, project names and tags all have to match ^[a-z0-9][a-z0-9-]*$ and be 128 characters or fewer. Uploads stop at 10 MB.

Markdown

Markdown pages are documents, not apps, so raw HTML inside a Markdown upload is escaped to visible text rather than rendered, and link and image URLs are limited to http, https, mailto and tel. GFM tables and fenced code blocks work. Add ?raw=1 to get the original Markdown back. Mirroring keeps the content type, so the Cloudflare copy renders the same way.

The index

GET / lists everything, grouped by project, newest first. The same data is available as JSON at /api/pages and /api/projects. That is how an agent finds work it did in an earlier session.

example-project
hero-variantHero, variant C2026-07-28
launch-planLaunch plan2026-07-24
perf-auditPerformance audit2026-07-19
unfiled
scratch-notesScratch notes2026-07-30
db-schemaSchema sketch2026-07-11

Agents

pageshelf comes with an MCP server that talks over stdio, so an agent can upload a page by calling a tool instead of running curl. It reads the same config file as the server, so you usually do not have to set anything up for it.

claude mcp add pageshelf -- bun run /path/to/pageshelf/src/mcp-main.ts
publish_pageCreate or replace a page, HTML or Markdown
get_pageRead a page back before changing it
list_pagesFind earlier pages, filtered by project or tag
list_projectsList projects, newest first
publish_assetUpload a shared image, stylesheet or script
delete_pageDelete a page and its mirror copy

There is also a skill in skills/pageshelf/ with the rules for writing pages: do not write your own <nav>, do not link external stylesheets, and send project and tags on every upload, because pageshelf only keeps what was in the most recent one. You have to install the skill yourself. Cloning the repo does not load it.

Security

Read this before you let anything else reach it.

This is on purpose. pageshelf is built for the case where you control who can reach the port. If you put it on the public internet, you need something in front of it to control reads, such as Cloudflare Access. The token only protects writes. SECURITY.md has the full details and lists everything the installer changes on your machine.

API

PUT /pages/:slugtokenCreate or replace. Takes ?project= and ?tags=
GET /pages/:slugopenServe the page. ?raw=1 gives you the source
DELETE /pages/:slugtokenDelete the page and its mirror copy
PUT /assets/:nametokenUpload a shared file. Type comes from the extension
GET /assets/:nameopenServe the file
GET /openList all pages, grouped by project
GET /api/pagesopenJSON list, newest first. Filter by project and tag
GET /api/projectsopenJSON projects with page counts
POST /_synctokenLocal only. Push everything to the mirror again

Configuration

The local server reads ~/.config/pageshelf/env. That is the same file the systemd unit uses, so bun run dev works without exporting anything. Keep it at mode 600.

PAGESHELF_TOKENrequiredToken needed for writes
PAGESHELF_HOST127.0.0.1Address to listen on. 0.0.0.0 serves the network
PAGESHELF_PORT8788Port to listen on
PAGESHELF_DATA_DIR~/.localWhere pages are stored. Default ~/.local/share/pageshelf
PAGESHELF_MIRROR_URLunsetMirror address. Mirroring is off until you set this
PAGESHELF_TAILNET_URLunsetReturned as urls.tailnet after a write

Cloudflare mirror

This part is optional. pageshelf works fine with just the local server. The wrangler.jsonc in the repo does not set a hostname on purpose, so deploying gives you a workers.dev URL and does not touch DNS on your account. If you want your own domain, put the route in wrangler.local.jsonc, which is git-ignored.

wrangler r2 bucket create pageshelf
wrangler secret put PAGESHELF_TOKEN   # same token as the local server
wrangler deploy

# then turn on mirroring locally and copy everything up
# PAGESHELF_MIRROR_URL=https://pages.example.com
curl -X POST http://pageshelf.local:8788/_sync \
  -H "Authorization: Bearer $PAGESHELF_TOKEN"

Source

github.com/hdprajwal/pageshelf. See the README, issues, releases and contributing guide. Built with Bun, Hono, TypeScript and Vitest. 63 tests. Four runtime dependencies: Hono, marked, zod and the MCP SDK.