# Pages

> Registered Markdown, HTML, and downloadable file pages for zylos. Use for styled documents or when a local file should be exposed as a safe attachment page. Agents register the source with the pages CLI and report the internal URL; files are served only after registration.

- Skill: `zylos-ai/pages` (Agent Skill, multi-file: 9 files)
- Install (CLI): `npx skillmds@latest add zylos-ai/pages`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zylos-ai/pages/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: zylos-ai (https://skillmd.com/u/zylos-ai)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/zylos-ai/pages

---


# Zylos Pages

Render Markdown and HTML files as styled web pages, or expose arbitrary files as safe download pages.

## Agent CLI (Local DB)

Use this CLI when an agent needs to register local Markdown/HTML/download files, manage share links, or add an allowed source root. It runs locally and writes the Pages DB/config directly; it does not use HTTP or tokens.

```bash
PAGES_DIR="$HOME/zylos/.claude/skills/pages"

# Register a local source file as a logical page. Default access is private.
node $PAGES_DIR/src/cli/pages.js register --source /absolute/report.md --uri reports/q3 --title "Q3 Report"

# List registered logical pages.
node $PAGES_DIR/src/cli/pages.js list

# Inspect live share links. Protection metadata is included, never plaintext.
node $PAGES_DIR/src/cli/pages.js shares reports/q3
node $PAGES_DIR/src/cli/pages.js shares --all

# The inverse: someone hands you a link, find out which document it is.
# Resolves expired and revoked links too, and takes the full URL or the token.
node $PAGES_DIR/src/cli/pages.js share-info https://domain/s/<token-id>

# Mint an unprotected link. NOT a default step — see Sharing below.
node $PAGES_DIR/src/cli/pages.js share reports/q3 --duration 7d

# Mint a protected link with a generated password. This explicit command
# returns the secret on stdout; deliver it only through the intended private DM.
node $PAGES_DIR/src/cli/pages.js share reports/q3 --duration 7d --password

# A provided password is read from stdin, never argv or a URL.
printf '%s\n' "$SHARE_SECRET" | node $PAGES_DIR/src/cli/pages.js share reports/q3 --password-stdin

# Explicit repeat retrieval. Ordinary shares/share-info never return plaintext.
node $PAGES_DIR/src/cli/pages.js share-password get https://domain/s/<token-id>

# Manage the password of an EXISTING share without changing its URL.
# enable/rotate generate a 6-digit password (or take one on stdin) and print
# the secret once; disable makes the link open without a password again.
node $PAGES_DIR/src/cli/pages.js share-password enable https://domain/s/<token-id>
printf '%s\n' "$SHARE_SECRET" | node $PAGES_DIR/src/cli/pages.js share-password rotate <token-id> --password-stdin
node $PAGES_DIR/src/cli/pages.js share-password disable <token-id>

# Custody keyring maintenance (init/rotate/retire) is rare operator work:
# see references/pages-cli.md before running share-password keyring commands.

# Revoke. Prefer --token: the uri form revokes EVERY token on that page,
# which will also kill links you did not mean to touch.
node $PAGES_DIR/src/cli/pages.js unshare --token <token-id>
node $PAGES_DIR/src/cli/pages.js unshare reports/q3

# Remove a logical page registration and page-id keyed share/session rows.
# The source file on disk is left untouched.
node $PAGES_DIR/src/cli/pages.js unregister reports/q3

# Add a local directory to the allowed source roots.
node $PAGES_DIR/src/cli/pages.js allow-root add /absolute/reports --name reports
```

Long-form parameter details and safety notes: `references/pages-cli.md`.

## Secure handoff

For a human to deliver a short-lived sensitive value, use the one-time local
client at `src/cli/secure-handoff.js`; do not place the value in a page, state
key, share password, command argument, environment variable, Issue, or log.
Spawn the client with fixed argv and write create/status/await/consume/revoke
JSON directly to stdin. It reaches the running Pages process through a `0600`
Unix socket that is not routed by Caddy. Give the human only the returned
`submitUrl`, retain the management token only in caller memory, and keep the
one-time await response out of routine output. Browser Fetch Metadata protects
same-origin POSTs through a stripped proxy; set an absolute `publicBaseUrl` as
the fallback for clients that omit it. Full protocol and cleanup guidance is
in `docs/secure-handoff.md`.

## Sharing

Registering a page protects it with the Pages owner password. `share` mints
`/s/<token>` and is unprotected unless `--password` or `--password-stdin` is
selected. The URL remains a bearer secret even when a second password is used.

So:

- **Registering is the default. Sharing is not.** Finish the job by
  reporting the internal URL or the file path.
- **Mint a link only on an explicit request** from whoever owns the
  document. State whether it is protected or unprotected when you hand it
  over. "Visible inside the company" is a different thing from "readable by
  anyone on the internet who has the URL" — do not treat the first as
  license for the second.
- **Prefer a duration over `permanent`.** Pick the shortest lifetime that does
  the job, whether or not a password is present.
- **Deliver passwords only in the intended private conversation.** Never put
  them in a project group, Issue/Task comment, PR, KB page, command argument,
  URL, or routine log. For Agent reads, send `X-Zylos-Share-Password` only on
  `GET`/`HEAD /s/<token>`, `/s/<token>.md`, or
  `/s/<token>/download`; it does not authorize writes.
- A protected link does not hide a parallel live unprotected link to the same
  page. Both owner consoles warn about this condition without blocking work.
- Independently unlocked shares coexist in one browser: each grant remains
  scoped to its own share/page and never authorizes another link. One Pages
  mount retains at most 16 grants within a 4096-byte recognized-cookie budget,
  evicting the least-recently-used grant first. Owner login/logout clears all
  presented grants. Legacy singleton browser sessions rotate automatically to
  the per-share form and remain recognizable only for their existing one-hour
  session lifetime.

Revoking:

- `unshare --token <token-id>` revokes exactly that link.
- `unshare <uri>` revokes **every** token on that page. Run
  `shares <uri>` first and look at what else is live — this is how a
  routine cleanup takes out a permanent link somebody was still using.
- **Revocation is a reversible marker, not destruction.** It sets a flag;
  the row and its token stay in the table, so a revoked link can be brought
  back by clearing that flag. This is deliberate — it is how a mistaken
  `unshare <uri>` gets undone — but it means "revoked" is not a guarantee
  that the URL can never work again. Do not rely on it as one.

Accounting for links:

- Rows are never deleted — not on expiry, not when the document itself is
  unregistered. Every link ever minted can still be traced back to the document
  it exposed. What dies is access, not the record.
- **Unregistering a document leaves a tombstone.** `unregister <uri>` keeps
  that page's share rows and stamps them with the uri the page had, because the
  uri lives in the page row and that row is about to go. The links stop working
  immediately — every access path resolves through the page row — but
  `share-info` still answers "that was `q3/plan`, and the document is gone".
  Live-exposure listings exclude tombstones: `shares --all` reports what this
  box actually serves, not what it remembers.
- `unregister` reports `tombstonedShares` (rows kept) and `removedSessions`
  (browser sessions destroyed). It used to report `removedShares`; a count of
  retained rows under a name saying "removed" is how an operator concludes the
  links were purged.
- `shares --all` lists every **live** share on the instance. Use it before
  concluding anything about this box's exposure; per-page `shares <uri>`
  cannot answer that question.
- `share-info <token-or-url>` goes the other way: hand it a link somebody
  sends you and it names the document, when it was shared, for how long, and
  its status — `active`, `expired`, `revoked`, or `document_deleted`. Ranked
  strongest claim first, so a revoked link that later lapsed still reads
  `revoked`, and a deleted document outranks a spent clock. It takes the full
  URL or just the token.

## Creating HTML Pages (CLI)

```bash
PAGES_DIR="$HOME/zylos/.claude/skills/pages"

# List available templates
node $PAGES_DIR/src/cli/pages.js templates

# Create a page from template (writes to the correct content directory)
node $PAGES_DIR/src/cli/pages.js create --template technical-proposal --slug docs/my-report

# Edit the file — replace {{PLACEHOLDER}} values with content
```

The page is now registered and reachable behind the pages password. That is
the end of the normal flow — report the internal URL and stop there.

**Do not create a share link as a routine step.** `share` mints a
`/s/<token>` URL that bypasses owner login. Create one only when the person who
owns the document asks for a link they can hand to someone, choose protected
or unprotected deliberately, and state that choice. See "Sharing" above.

Templates: `technical-proposal`, `research-report`, `comparison`, `evaluation`.

## Registering a Downloadable File

```bash
PAGES_DIR="$HOME/zylos/.claude/skills/pages"
node "$PAGES_DIR/src/cli/pages.js" register \
  --source /absolute/file.pdf --uri files/report --title "Report PDF"
```

Only sources whose requested extension and real (symlink-resolved) extension
both match `.md` or `.html` enter a renderer. Every other regular file becomes
`type=attachment`. The owner opens `/pages/p/files/report` and downloads from
`/pages/p/files/report/download`; shared visitors use `/pages/s/<token>` and
`/pages/s/<token>/download`. Never create a share unless the document owner
explicitly asks for one.

Attachment downloads are capped by `security.maxAttachmentSizeBytes` (50 MiB
by default), use a MIME extension allowlist with an octet-stream fallback,
force attachment disposition, and never enter raw Markdown, state, embedded
photo, logical-asset, render, or page-cache paths.

## Cross-Page Navigation

Pages never lets another origin frame a document. Ordinary responses carry
`Content-Security-Policy: frame-ancestors 'none'` and `X-Frame-Options: DENY`;
HTML artifacts served raw (`?raw=1`) or through a share link instead carry
`frame-ancestors 'self'` and `SAMEORIGIN`, which permit any same-origin
ancestor — in practice Pages' own wrapper iframe — while still refusing
every cross-origin frame. Some host clients open a clicked link inside
an iframe or embedded webview; in that cross-origin context an in-place link
from one Pages document to another shows a browser-level refusal, while the
same URL opens fine as a top-level navigation (new tab). The fix is always
the link, never the headers.

- A link from one Pages document to another Pages document defaults to
  `target="_blank" rel="noopener noreferrer"` — the new tab is a top-level
  navigation, so the anti-framing protection never blocks it.
- Same-page fragment links (`href="#section"`) stay as they are: they navigate
  within the already-loaded document and are never affected.
- Never weaken `frame-ancestors` or `X-Frame-Options` to make embedded
  navigation work.
- In HTML artifacts, write the anchor attributes directly. In Markdown
  sources, standard `[text](url)` syntax cannot express `target`/`rel`, but
  the default sanitizer (`allowRawHtml: false`) keeps inline HTML anchors
  with `href`, `title`, `target`, and `rel` — write the cross-page link as an
  inline HTML anchor. See `references/html-rendering.md` for the compliant
  anchor example.

## Quick Start (Markdown)

```bash
PAGES_DIR="$HOME/zylos/.claude/skills/pages"
# Content root = contentDir in ~/zylos/components/pages/config.json
CONTENT_DIR="$(node -p "require(process.env.HOME+'/zylos/components/pages/config.json').contentDir || process.env.HOME+'/zylos/http/public/pages'")"

# 1. Write the markdown file under the content root (or any allowed source root)
echo "# Hello World" > "$CONTENT_DIR/hello.md"

# 2. Register it — pages are ONLY served after registration
node "$PAGES_DIR/src/cli/pages.js" register \
  --source "$CONTENT_DIR/hello.md" --uri hello --title "Hello World"

# 3. Report the internal URL: https://domain/pages/p/hello
#    (behind the pages owner password; see Sharing before minting links)
```

Writing a file alone is not enough: unregistered files under the content
directory are not served (the request 404s after login). This changed in
v0.9.0 — registration is the gate for every page.

## References

| Document | When to read |
|----------|-------------|
| `references/pages-cli.md` | When registering local source files, sharing/unsharing logical pages, adding allowed source roots, or checking the local DB CLI contract |
| `references/html-rendering.md` | When creating HTML artifacts (`.html` files), choosing between Markdown vs HTML mode, or needing to understand CSP constraints, dark mode, CJK typography, and responsive design best practices |
| `templates/html/README.md` | When generating HTML reports — lists available templates, usage instructions, and the shared design system |

## HTML Report Templates

Five standalone HTML templates are available in `templates/html/`:

| Template | Use case |
|----------|----------|
| `research-report.html` | Research/investigation reports with summary, findings, and recommendations |
| `technical-proposal.html` | Technical proposals with architecture sections, pros/cons comparison |
| `comparison.html` | A-vs-B product or technology comparisons with scoring |
| `evaluation.html` | Candidate or vendor evaluations with rating breakdowns |
| `interview-questions.html` | Interview question guides with hypotheses, pacing notes, and judgment framework |

All templates share `templates/html/base.css` (design tokens, dark mode, CJK fonts, responsive layout). Copy a template, fill in content, and save as `.html` in the pages directory.

