↖ index about
projector 2026.09.12.1933

projector

One long-lived server on one fixed address that shows two things: dev sites while they are running, and pages that were pushed to it. Its index lists everything it is showing at the moment you ask.

It exists to close a loop. An agent renders something, looks at it, and hands a person a URL only when the result is worth their eyes — instead of describing the result in prose, or asking someone to run something to see it.

What it is not

Not a CMS. Nothing on the projector is edited in place. A page changes because it was pushed again; a site changes because the server behind it rebuilt. There is no editor, no draft state, no revision history.

Not a host. It serves nothing of its own. Until something is pushed to it or dials into it, every address on it is empty, and empty is the normal state.

Not a store of record. Everything it stores expires. Anything worth keeping belongs in a git repository, in the project's real files — what is on the projector is output, never the source.

Not public. It runs on a private network, with no accounts, no login and no tokens. The network is the boundary; the projector itself checks nothing.

How it works

Two kinds of thing live on it, and they are addressed differently because they need different things: a site that is running somewhere else, and an artifact pushed here and stored.

A live site

a dev pipeline dials in and holds a socket open

served on its own subdomain origin

nothing is stored, and it leaves when the socket drops

A pushed artifact

uploaded through a push door and stored

served under a random id on the projector's own address

expires on the retention clock unless it is marked permanent

A live site

A dev pipeline dials out to the projector and holds one socket open; the projector serves requests back down it. The direction is the whole trick — nothing has to be reachable from the outside, so a laptop on a home connection shows a site as well as a machine in a rack does.

A live channel gets its own origin, a subdomain of the projector's host. A site needs root-absolute paths, its own cookies and its own sub-resources, and those only work when it owns an origin.

That origin is exactly one DNS label in front of the projector's own host:

projector host   projector.the.slyfox.com.au
channel origin   <label>.projector.the.slyfox.com.au

The label is the channel address with dots turned into single hyphens and the / turned into a double hyphen, so design.example.com/dev is design-example-com--dev (wire.Label, matched back in internal/server/host.go). One label, because the projector is fronted by a single-level wildcard certificate — *.projector.the.slyfox.com.au covers a.projector.… and never a.b.projector.….

So a projector can hold channels only when its own host sits directly under a wildcard it has a certificate for.

Nothing about a live channel is stored. When the socket drops, the channel leaves the index, so there is no stale copy of a site to mistake for a running one.

Which artifact you want

A pushed artifact is one of four kinds. The kind is what the projector renders it as, and it is chosen before the push rather than discovered afterwards.

documentone page, read top to bottom — the default
deckpage files, shown one page at a time
transcripta session as it happened, turn by turn
notesa directory of files, browsable as a tree

document — one markdown or HTML page, scrolled. For anything a person reads start to finish: a report, a review, a plan, a set of findings. It is the default. It is the wrong choice when what you have is a set of files, or a session that happened rather than a piece of writing.

deck — a set of page files presented one page at a time. For something shown rather than sent: a walkthrough, a proposal, a review someone talks through. It is read at its own address in three frames — paging, full screen at ?present, and one scroll that prints at ?read. It is the wrong choice for anything read by scrolling or searched for a sentence, because a page is a fixed canvas and a point that needs four paragraphs is a document.

transcript — a recorded session, rendered turn by turn. For publishing what was actually said, where the exchange itself is the evidence: the page carries an anchor per turn, a find over the whole session, tags, and a fork that pulls a range out as markdown with its provenance attached. Nobody writes one by hand. pipeline transcript reads the session file off disk and pushes it, so the session never passes through a model's context. It is the wrong choice when what is wanted is a summary — that is a document which cites the transcript.

notes — a pushed directory, rendered as a browsable file tree. For a set of files that already exists on disk and is worth handing over whole. It is the wrong choice when there is one file, which is a document; when the order of the files is the point, which is a deck; and when the files are pictures, because nothing serves a file's own bytes out of a collection yet.

Two of the four are pushed as one body of content — document and transcript. The other two are pushed as a set of files, and which of those two a set becomes is decided by the files: a set carrying a deck.yaml at its root is a deck, and every other set is notes.

document, transcript, deck and notespassthe four kinds, all built
A kind for source codeskipproposed in #92, not built
gallery, wiki and vaultskipdesigned as collection kinds, not built
Images inside a collectionskipno route serves a file's own bytes yet (#89)

A pushed document

A push is one self-contained page: no sub-resources, no session, so it needs no origin. It is stored, and it is served under a random id on the projector's own address. Markdown is rendered by the projector into its own design; a whole HTML document is served exactly as it was written.

A pushed page has no name. It has an id, that id is its URL, and the id is how it is updated: a push says which id it replaces, or says nothing and publishes a new page. An id cannot collide, and it is the one handle that survives being pasted into a session with no memory of where it came from.

Replacing a page moves its address. The push allocates a new id, and the id it replaced stops serving the old content: it serves a page saying where the content went. That is deliberately not a redirect. A redirect swaps the page under someone who was sent somewhere specific and gives them no way to tell, whereas a link shared before the last push should say what happened. The exception is a pinned page — see below.

A link that cannot work is not drawn as a link. A page is served under /d/<id>, so a relative link in it points inside that page's own address and reaches nothing. Those render as plain text — the words stay, the dead destination goes. Absolute links, anchors within the page, and the page's own query views are untouched.

A stale id still works. The id a push names is followed forward to the page that replaced it, however many pushes ago that was, so a link someone pasted last week updates the current page rather than forking a second one.

A pushed collection

A collection is a directory instead of a file. It gets one id, one URL, one pin decision and one index row, the same as a pushed page, and it is either a deck or a browsable folder tree.

A directory does not fit in one body, so it is uploaded in three calls — open, add files a chunk at a time, commit — which is also what makes a partial push possible: a later push sends only the files that changed and names the ones that went, and the server carries the rest forward without them crossing the network again. The instance reports its own limits when the push opens, so a client chunks to fit rather than guessing.

A deck is a set of files, not a file. One markdown file per page, plus a deck.yaml at the root of the set carrying the deck's title, its description, and pages, an ordered list of the page files. That list is the page order; nothing is sorted and no order is read off a filename. Inside a page, --- is an ordinary horizontal rule and separates nothing, while ||| cuts the page into equal columns. There is no single-file deck: a deck pushed through either document door is refused.

A notes set is read as nothing but its tree. It is what a collection without a deck.yaml becomes, and it is the only collection kind other than deck. Nothing hands a collection's files back, so keep what you pushed.

Expiry, hold, ignore and permanent

Everything stored expires

Every artifact is swept a fixed window after it was created unless it is marked permanent, and there is no instance-wide way to turn expiry off. Pin is a longer clock, not an exemption.

Anything worth keeping belongs in a git repository. What is on the projector is output.

Four holds, one axis, from the index row:

  • Defaultretention.window (seven days). The URL rotates when the page is replaced.
  • Pinretention.pin (ninety days), from the page's creation, not from the click. Still rotates on replace. A person's control, and only a person's (#114). No tool or push argument an agent can call sets or clears it.
  • Permanent — out of the purge, URL fixed. A client may set this on a push; only a person takes it off. The on-disk store is still called published; the word on the page is Permanent (#241).
  • Ignore — hidden until asked. Same clock as default: hiding never lengthens life, and leaving pin or permanent via ignore drops onto the default window.

A live channel is not holdable and does not need to be. Nothing about it is stored and the sweep never reads it: it is on the index while its socket is open and gone when it drops.

Permanent is not about who can see a page. It changes nothing about which host serves it or who can reach it. Permanent collections sit in their own band on the index.

Sharing is a person typing an email on the index. The link in the mail is the capability — no accounts. A grant cannot outlive the hold: sending a longer link asks to pin or make the page permanent first. An agent never mints, sends or revokes a grant. The redeem URL is not /s/ (that prefix is sessions).

Mail is [mail] in the config file. Empty smtp means mint still works and the copy URL is the fallback. A lab Mailpit is plain SMTP, no user, no TLS:

[mail]
smtp = "10.0.10.1:1025"
from = "projector@the.slyfox.com.au"
starttls = false

The doors

Besides the index there are three, all on the same fixed address as everything else: two that something is pushed through — the document route, and the collection route, which takes a set of files in three calls — and one a dev pipeline dials into. All of them are behind a paired token; see Pairing below.

There is no MCP surface. There was one, and deleting it is what made the token above a single mechanism rather than two: everything that writes here is a program a person installed, and a program can carry a token.

What a pushed page may contain — the components the renderer accepts and the fields each one takes — is generated from the code that renders them and served at /skill as markdown. It is written for agents, and it is deliberately not repeated here: a second copy of it in prose is a second answer that can be wrong.

Pairing

A machine that pushes carries a token, and the projector mints it. The client runs projector pair <url>, the projector answers with a short code, and the client prints it. The index lists the request with that code, the label and the hostname beside it, and a person clicks approve — comparing the code on the screen with the code in the terminal. Nobody types anything; the code is a match, not an entry field.

The token is 32 random bytes, returned once and kept as a SHA-256 hash with the label, the hostname and a last-seen. It travels as Authorization: Bearer <token>. The paired machines are listed in a drawer at the foot of the index, and revoking one there stops it on its next request.

Enforcement is off until -require-auth is set, and that is the only order it can be turned on in: pairing is itself an unauthenticated request, so nothing can pair with an instance that already refuses them. Deploy, pair every client, set the flag, restart.

The tokened routes are the machine routes: the document and collection pushes, the append, the theme, the delete and the tunnel dial-in. The controls a browser posts — pin, ignore, publish, fork and a reply on a session page — are not tokened, because a browser on this projector holds no token. What guards those is what has always guarded them: the loopback bind, the tailnet, and Caddy refusing the write paths on the public vhost.

Working on dev: the prod projector shows it

The projector renders itself, so working on it means one projector displaying another. This is the loop, stated once so it does not have to be re-explained.

Two instances are in play:

  • Prod is hades serving the main branch at https://projector.the.slyfox.com.au. Nothing on dev exists there.
  • Dev is this checkout, run locally. It is a complete projector of its own: its own port, its own data directory, its own pairing and tokens.

To see the dev branch, run pipeline dev start --projector in this repo. pipeline builds the branch, runs the binary as the projector service (pipeline status names the ports), and registers the channel projector.the.slyfox.com.au/dev on prod. Prod then tunnels every request on that channel to the local dev instance. Prod is a live window onto the dev instance — nothing is deployed, and closing the pipeline closes the window.

The consequences, which are the confusing part:

  • A page seen through the channel is served by the dev instance. New dev features work there and nowhere else.
  • Content for testing dev features is pushed to the dev instance's local port (pipeline status names it), never to prod. A push to prod lands on main's code and will not show a dev feature — a folder that renders as a new kind on dev is just a notes collection on prod.
  • The dev instance takes tokened writes like any projector: pair with projector pair http://localhost:<port> and approve the code on its index — the index is reachable through the channel, so the approval click works from anywhere.
  • The channel holds prod's normal channel rules: one holder, displacement on a second dial-in.

The dev instance cannot hold channels of its own

A dev instance is reached through a channel, so its host is already one label deep:

dev instance host   projector-the-slyfox-com-au--dev.projector.the.slyfox.com.au

A channel registered on it would need a label in front of that, which is two labels under the wildcard:

design-example-com--dev.projector-the-slyfox-com-au--dev.projector.the.slyfox.com.au

DNS answers for that name — the zone's wildcard A record catches any depth — but TLS does not. Caddy has no certificate covering two labels and the connection fails at the handshake with tlsv1 alert internal error, before any HTTP happens. The channel registers on the dev instance and then has no address anyone can open.

Two ways out, both on nagare/projector-app#211:

  1. Issue a second wildcard on the front proxy for the dev instance's own host. Configuration only, no code, and it covers one level of nesting per certificate.
  2. Serve nested channels on a path rather than a subdomain. No certificate needed at any depth, at the cost of rewriting root-absolute paths — which is the thing the origin exists to avoid.

Until one of those lands, a project being developed against the dev branch runs its channel on prod and gets prod's chrome. Its files are live off the local disk either way; only the projector-rendered parts of the page come from main.

Working on the design system and a design project at once

Temporary, and only while the projector's own design system is being built with the projector. Delete this section when nagare/projector-app#211 lands.

The chicken and egg: a design project's files want a live channel, and the sheet around them is rendered by whichever projector holds that channel. Prod's sheet is main's. The dev instance has the sheet you are editing and cannot hold the channel. So the two halves are worked in two places at once.

The files — live channel on prod.

cd src/design
pipeline dev start --projector=https://projector.the.slyfox.com.au

Edits appear immediately. The sheet around them is main's.

The sheet — pushed collection on the dev instance.

projector notes push src/design/site --projector http://127.0.0.1:<dev port>

pipeline status names the port. A pushed design system is an artifact at /d/<id>/, one label deep on a host with a certificate, so nothing about nesting applies. The push reuses the same id, so the URL is stable.

What that costs is live reload: an artifact is a snapshot, and a sheet change means pushing again. That is the whole of the trade — reload on the files, current chrome on the sheet, and not both in one window until the certificate question is settled.

Building

One Go binary with no runtime dependencies. The compiled stylesheet is committed beside its source, so a clean checkout builds with go build ./... and no CSS toolchain installed; nix build produces the packaged binary. The Makefile holds the task targets, including the one that compiles the stylesheet.

docs/ holds the design record — why it is shaped this way, and what was tried and dropped.


This file is what a running projector serves at /about. Someone opening the repository and someone opening the page read the same words, because there is only one of them.

/about is not a second set of docs, and it is not a maintenance surface to delete. It is this file, embedded at build time by readme.go and rendered per request, wrapped with three facts only a running instance knows: its version, what it is showing, and the expiry window it is actually sweeping on. Deleting the route would not remove a document, because there is no second document — it would remove the only place those three facts are stated. Proposed and rejected in #72.

This instance

version2026.09.12.1933
showing1 pushed
expiryartifacts expire after seven days; pinned after 90 days

Configuration

Every per-instance setting, its effective value, and where that value came from: default is compiled in, config is the file named by -config, flag is a command-line flag. The same caps are published as JSON at /_limits for the tools that push here.

server.listenunix:/var/sites/go.rocky.slyfoxhosting.com.au/app.sock flag
server.data/var/sites/go.rocky.slyfoxhosting.com.au/data flag
server.basehttps://go.rocky.slyfoxhosting.com.au flag
server.base_host default
retention.window168h0m0s default
retention.pin2160h0m0s default
images.max_per_push24 default
images.max_bytes2097152 default
images.max_total_bytes4194304 default
images.formatswebp, png, jpg, jpeg, gif, avif default
embeds.posterstrue default
embeds.timeout10s default
embeds.max_poster_bytes2097152 default
embeds.max_video_bytes8388608 default
collections.max_files500 default
collections.max_bytes104857600 default
collections.max_file_bytes16777216 default
collections.max_find_bytes25165824 default
push.max_wire_bytes33554432 default
push.max_decompressed_bytes67108864 default
push.max_control_bytes4096 default
append.max_fragment_bytes65536 default
append.max_document_bytes4194304 default
append.max_per_minute60 default
append.burst30 default
tunnel.max_request_bytes8388608 default
tunnel.max_frame_bytes33554432 default
tunnel.proxy_timeout30s default
auth.requirefalse default
mail.smtp default
mail.from default
mail.user default
mail.password default
mail.starttlstrue default