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.
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_page | Create or replace a page, HTML or Markdown |
| get_page | Read a page back before changing it |
| list_pages | Find earlier pages, filtered by project or tag |
| list_projects | List projects, newest first |
| publish_asset | Upload a shared image, stylesheet or script |
| delete_page | Delete 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.
- Reads are not authenticated. There is no login and no per-page permissions. Anyone who can reach the port can read every page you have uploaded.
- HTML you upload will run. pageshelf is not a sandbox, and all pages share one origin. Only upload HTML you would be happy to run yourself.
-
By default it only listens on
127.0.0.1. To serve your LAN or tailnet you have to setPAGESHELF_HOST=0.0.0.0yourself. A tailnet is a reasonable place to do that. A shared or public network is not.
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/:slug | token | Create or replace. Takes ?project= and ?tags= |
| GET /pages/:slug | open | Serve the page. ?raw=1 gives you the source |
| DELETE /pages/:slug | token | Delete the page and its mirror copy |
| PUT /assets/:name | token | Upload a shared file. Type comes from the extension |
| GET /assets/:name | open | Serve the file |
| GET / | open | List all pages, grouped by project |
| GET /api/pages | open | JSON list, newest first. Filter by project and tag |
| GET /api/projects | open | JSON projects with page counts |
| POST /_sync | token | Local 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_TOKEN | required | Token needed for writes |
| PAGESHELF_HOST | 127.0.0.1 | Address to listen on. 0.0.0.0 serves the network |
| PAGESHELF_PORT | 8788 | Port to listen on |
| PAGESHELF_DATA_DIR | ~/.local | Where pages are stored. Default ~/.local/share/pageshelf |
| PAGESHELF_MIRROR_URL | unset | Mirror address. Mirroring is off until you set this |
| PAGESHELF_TAILNET_URL | unset | Returned 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.