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 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
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.
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 builtgallery, wiki and vaultskipdesigned as collection kinds, not builtA 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:
- Default —
retention.window(seven days). The URL rotates when the page is replaced. - Pin —
retention.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
mainbranch at https://projector.the.slyfox.com.au. Nothing ondevexists 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
devfeatures work there and nowhere else. - Content for testing dev features is pushed to the dev instance's local
port (
pipeline statusnames it), never to prod. A push to prod lands onmain'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:
- 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.
- 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
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.