learn / 02·15 — under nexus · changelogs

the page thatTELLSits own history

Most changelogs are marketing — hand-curated, lagging, written by whoever remembered. A published Nexus app has a better one already: its own git log, served back out through the anonymous public plane as /_changes, and consumed by the page it describes. The changelog isn't a page someone writes — it's the repo, told live, and every commit links to the mirror where you can check it.

changelogs11 min read
A monumental wall-sized ledger printing its own history in glowing green type, a tiny figure at its base reading a freshly-stamped line as the page above writes itself — bright 1970s sci-fi paperback style

changelogs are MARKETING

Every product has a changelog page, and almost every changelog is marketing. It's hand-curated, it lags the actual work by days or weeks, and it's written by whoever happened to remember — which means it flatters, and it omits. Nobody can check it against anything, because there's nothing underneath it to check against.

The moment agents start committing to your site, that hygiene problem sharpens into a trust problem. The question is no longer "is the changelog tidy" — it's who changed this page, when, and can I verify it? A visitor landing on an agent-tended site has every right to suspect theater. This lesson is the answer to that suspicion: not a nicer changelog, but a changelog you can't fake, because it's the same git history that built the page.

the history ENDPOINT

/_changes /slash·un·der·score·chan·jiz/ endpoint

1. an app's real git log plus its agent's live status, served anonymously by the app itself over the public content plane, and consumed by the very page it describes — verifiable history, not marketing.

The response is one JSON blob with two halves, and each half comes from somewhere honest. The changes half is the literal git log; the agent half is the keeper's current heartbeat. Neither is composed by hand — both are read off the running engine at request time.

halfwhat it iswhere it comes from
changeslast 30 commits, newest first — {sha, ts, author, msg} eachWorkbooks.Git.log_entries
agentthe keeper's live status — active, running, last run, next run, lifecycleWorkbooks.Keeper.status

That's the whole contract. A 30-commit cap, a status object, one anonymous GET. The page on top turns it into a live, classified, narrated feed — but the source of truth is this, and nothing more.

anonymous and READ-ONLY

This feed doesn't live on the same HTTP surface as your dashboard. A Nexus runs two separate planes — and /_changes is firmly on the public content plane, which inherits a short list of refusals. No Workbooks.Auth plug. GET only. No Dock, so no call, no commands, no build, no agents, no secret access. And the pages it serves carry no call-home script. The feed can be read by anyone and written by no one.

Every response on this plane self-identifies, so you always know who's talking: a header x-served-by: workbooks-runtime, and HTML bodies get a marker comment injected before </head>. The underscore prefix on /_changes and /_activity is deliberate too — it keeps these engine endpoints from colliding with your own workbook paths.

App resolution is by host. The engine asks Workbooks.Domains.resolve which app this request is for: a registered custom host wins, otherwise it falls back to the leftmost DNS label — so demo.apps.example resolves to the workbook named demo. An unknown host is a flat 404, no app for host.

flowchart LR
  b["browser"] -->|"GET /_changes
(Host: demo.apps.example)"| h["public plane"] h --> r{"Domains.resolve
(host)"} r -->|"known host or
leftmost label"| repo["the app's git repo"] r -->|"unknown"| x["404 — no app for host"] repo --> j["JSON: changes + agent"] style repo fill:#aee5c2,stroke:#121316,stroke-width:2.5px style x fill:#ffffff,stroke:#121316 style j fill:#13d943,stroke:#121316

There is an authed twin. The control plane exposes /rcp/changes for the inspector's agent tab — same data, but it resolves the app from your authenticated tenant instead of the request host. Its source comment is the canon for this whole lesson: the whole point is that the changelog is verifiable history, not marketing.

what one change LITERALLY is

There is no changelog database. A change entry is one line of git log, parsed. The engine runs a single formatted log command and splits each line on tabs into a four-field map — short sha, a unix-seconds integer timestamp, the author name, and the commit subject — newest first, capped at 30:

git log --format=%h%x09%ct%x09%an%x09%s
        │     │     │     └─ %s  subject  → msg
        │     │     └─ %an  author name   → author
        │     └─ %ct  commit time (unix)  → ts
        └─ %h  short sha                   → sha
tokenfieldwhat the page does with it
%hshalinks to the public mirror — the proof you can click
%cttsrenders relative time — Ns / Nm / Nh / Nd ago
%anauthorfallback attribution when the message has no tag
%smsgclassified by its leading tag: prefix

Attribution is real because commit identity is auth identity. The tenant repo lives at WB_DATA/<tenant>, and every commit's author name and email are set from the authenticated tenant — the email is <tenant>@workbooks.local. You can't forge an author by typing a different name into a field; the engine stamps it.

And agents never run git natively — there is no bash outside the sandbox. An agent calls a git tool with only a commit message; the host does the rest on its behalf: add -A, then commit with hooks disabled, then push. The agent supplies intent; the engine supplies identity and mechanics. That's why the author column can be trusted.

committed means LIVE

Depth rung — skippable, but it's the line that makes the feed honest in practice. When the host commits on an agent's behalf, a successful commit immediately publishes: inside the same call, the engine mirrors content/** and blog/** into the live site directory before it even pushes. So in this ecosystem, the word committed always implies live.

This wasn't always true, and the bug that forced it is instructive. The lander once shipped blog posts that were committed but returned 404 — because the run died after the commit but before a separate publish step ran. Folding publish into commit closed that gap: a commit that exists in the log is a commit whose content is on the page.

sequenceDiagram
  participant A as agent
  participant T as git tool
  participant H as host
  participant F as /_changes feed
  A->>T: commit(message)
  T->>H: add -A → commit (hooks off)
  H->>H: mirror content/** + blog/** → site dir
  H->>H: push origin HEAD
  Note over H: sha gets a suffix:
(pushed) / (push failed: …) / (no origin remote) H-->>F: next poll shows the new commit

The push outcome is appended to the returned sha as a literal suffix — (pushed), (push failed: …) sliced to 120 chars, or (committed; no origin remote to push) — so the caller always learns what actually happened to the remote. Two guards keep this safe: an auto-generated .gitignore means add -A can never sweep in a signing key or session data, and safe.directory=* is set on every git op — without it, a uid mismatch on the deployed data volume once made git refuse everything, and agents wrote to disk for days that never shipped. The gitops lesson covers the inbound rail — how a human's pushed code merges in without clobbering agent data.

who made this COMMIT

Everything so far is the engine side. Now the page eats its own feed. The living-lander's frontend pulls /_changes and classifies every commit by one rule: a commit's type is its message tag. A message shaped tag: … is matched against a small table, and a known tag means an agent did it — with a name, a color, and a pill. The tag table is the diagram here:

tagwhocolor
addagentlive green
remagent#b48cff
composeagent#4dd6c4
strategyagent#ff9e7a
blog · tweetagent#5aa7ff
auditagentamber
plan · planning · keeperagentfaint

The matcher runs a regex for a leading tag: on the message. If the tag is in the table, it's that agent, that color. If there's no known tag but the author lowercases to the agent's name, it's still the agent, with a fallback tag of work. Everything else is a human. And the displayed message has its tag prefix stripped — msg.replace(/^[a-z]+:\s*/i,'') — so the pill carries the type and the text stays clean.

Three real commits, classified. blog: notes from tending the pricing sectionblog is in the table, so: agent, blue pill reading blog. polish the footer by author waldo — no known tag, but the author matches the agent, so: agent, fallback tag work. tighten hero copy by author shane — no tag, no agent author: human. Because classification is page-side, you extend it for your own agents by adding rows to that map — it's a convention, not a constant. (Worth saying plainly: a human who types blog: … will render as the agent. The honesty section returns to this.)

quiet humans, loud AGENTS

The timeline is opinionated about volume. Consecutive human or system commits collapse into one foldaway <details> whose summary reads N updates by the team — so a flurry of human housekeeping doesn't drown the feed. A single human commit renders inline. But agent commits always render individually, each with its tag pill and its short sha linked to the public commit on the mirror. The design choice is deliberate: agents narrate loudly because their work is the thing under scrutiny; humans stay quiet by default.

So those three commits from above, if shane's two follow-ups are also human, fold together: the blog commit stands alone as an agent line, and the three human commits become one summary, 3 updates by the team. Relative time is computed the obvious way — under a minute is Ns ago, under an hour Nm ago, under a day Nh ago, else Nd ago — and the countdown to the next keeper run reads any moment once it's due.

One discipline keeps the readouts honest: there is one poller. The page's hero build-ticker doesn't open a second connection — it subscribes to the same feed via onFeed, and a late-mounting subscriber immediately gets the last snapshot. The ticker even reuses the engine's own classify() through a shared describeCommit "so the ticker can't drift from the panel." Two readouts, one truth.

flowchart TD
  feed["/_changes — one poller"] --> seen{"new sha?
(seen Set)"} seen -->|"yes, agent commit"| line["render individually
tag pill + sha → mirror"] seen -->|"yes, human/system"| grp["fold into
N updates by the team"] feed --> onfeed["onFeed subscribers"] onfeed --> ticker["hero ticker
(same classify, no 2nd poll)"] style feed fill:#aee5c2,stroke:#121316,stroke-width:2.5px style line fill:#13d943,stroke:#121316 style grp fill:#ffffff,stroke:#121316

A fresh add: commit does a little extra: the page re-fetches its section manifest and DOM-diffs it, typing new sections in character by character and green-washing changed blocks. The source is explicit that this is cosmetic streaming over a plain swap — it is not a real CRDT — the canonical state is still the committed file.

narrating the RUN

Depth rung — skippable. /_changes tells you what already happened. Its sibling /_activity narrates what's happening right now, on the same public plane with the same refusals. It returns the keeper's status plus the tail of _steps.jsonl — the agent's step telemetry in the tenant repo — plus a cosmetic narration line.

Steps get slimmed for the public. Each event becomes {tool, ts, agent, target}, where target is whichever of path, cmd, pipeline, query, or url the step had — sliced to 120 chars. There are two shapes: a singleton feed {agent, steps, thought}, and — when more than one agent is configured — a multi-agent shape {agents, wire, agent} with per-agent status, a merged wire of the last ten steps tagged by agent, and a legacy agent block for the busiest one so an unaware frontend still renders.

sequenceDiagram
  participant P as the page
  participant A as /_activity
  participant K as keeper
  participant S as _steps.jsonl
  participant T as Thoughts.current
  P->>A: GET /_activity
  A->>K: status()  (from :persistent_term)
  A->>S: tail — slim to {tool, ts, agent, target≤120}
  A->>T: a ≤8-word caption (only if someone's watching)
  A-->>P: {agent, steps, thought}
  Note over T: idle → newest daydream from rem/daydreams.json
  

The thought is a tiny side model captioning the step feed in eight words or fewer. It is purely cosmetic and fully decoupled from the agent runtime — lazy and debounced on a 20-second TTL, generated only when someone is actually watching, cached per agent, and degrading to the last cached line on failure. When the agent is idle, the thought falls back to the newest entry from rem/daydreams.json — the daydream the keeper left behind. It is a caption, not chain-of-thought, and the page never pretends otherwise.

thinking, HONESTLY

This is the section that earns the lesson. A live "watch the agent work" feed is exactly where products lie — they animate a fake cursor and call it transparency. This one refuses to, by deriving its claims from the data instead of performing them.

The keystone is a freshness window. The follow-mode loop polls /_activity every 2.5 seconds, and STEP_FRESH_MS is 25,000. A step older than 25 seconds means the page says thinking — because that's what the model is actually doing between tool calls. The source comment is the design thesis: the model generating between tool calls is most of an agent's wall time, and ~90% real beats 100% theatrical. It's one three-place state machine — on-page, off-page, or thinking — and nothing else.

stateDiagram-v2
  [*] --> onpage: fresh step, target on this page
  onpage --> offpage: fresh step, target elsewhere
  offpage --> onpage: fresh step returns here
  onpage --> thinking: no step for 25s
  offpage --> thinking: no step for 25s
  thinking --> onpage: a fresh step lands
  note right of thinking
    honest by derivation —
    the model really is
    generating between calls
  end note
  

Privacy is enforced on the same feed. A step's target is never shown raw if it's a private or external API — it's mapped to a label. Search providers like dataforseo become checking search data; model hosts like openrouter / anthropic / openai become thinking; api.x.com becomes posting an update; GitHub APIs become reading the repo; and a catch-all regex labels any other api.* host as working. So a step whose target is https://openrouter.ai/api/v1/... renders only the word thinking — never the URL, never the args, never the response.

And the file previews on the page come from the public mirror — the viewer shows the last committed bytes, "the same source you'd see on github." An uncommitted write 404s and renders as draft, not yet committed, rather than leaking working-tree state. The visitor is never hijacked either: the embed card is a link, jumping is always the visitor's click, and the page never scrolls itself under them.

One worked window, end to end. A real step on disk:

{"tool":"vfs_write","ts":1765490012,"agent":"waldo",
 "args":{"path":"content/sections/07-pricing.html"}}

/_activity slims it to {"tool":"vfs_write","ts":1765490012,"agent":"waldo","target":"content/sections/07-pricing.html"}. The page resolves that path to the [data-grown="07-pricing"] element and parks the cursor in a typing descent. Twenty-five seconds later with no fresh step, the portal flips to thinking — true, not theatrical. That's the whole machine.

what it ISN'T

Honesty section. The good version of this feature is the one that tells you its own edges, so here they are, plainly.

  • Thirty commits. The feed is hard-capped at 30 — it's a recent-history window, not a full archive. For the whole story, you read the repo.
  • Polling, not push. The page refreshes the changelog every 15–20 seconds (and follow-mode every 2.5s). There are no websockets here; "live" means "polled often," not "instant."
  • Classification is convention. Who-did-what is inferred from a message tag and an author name — not signed, not cryptographic. A human who types blog: … renders as the agent. The author column is trustworthy; the type pill is a helpful guess.
  • The thought is cosmetic. It's a side model's caption, debounced and decoupled — not the agent's real reasoning. Treat it as a label, not a transcript.
  • _steps.jsonl is editable today. The step log is plain JSONL in the repo — readable and, for now, editable. Tamper-evident provenance is the frontier the ledger lesson takes up; this page doesn't claim signing it doesn't have.
  • It needs a keeper. The agent half of the feed is the keeper's status — no keeper running, no agent block. The changes half still works; the live-narration half goes quiet.

questions people actually ASK

Do I get this for free when I publish?

Yes. It's the public plane plus your repo — there's nothing to build. Publish an app on a Nexus and /_changes already answers on its host, returning your real git log and (if a keeper is running) its status.

Can visitors write anything through this?

No. The feed is on the anonymous content plane: GET only, no auth, no Dock — so no commands, no build, no agents, no secret access. It can be read by anyone and written by no one.

Can I change the tags and colors?

Yes — classification is page-side. The type map lives in the frontend, so you add rows for your own agents' tags and pick their colors. The engine just serves commits; the page decides how to read them.

Why does my human teammate show as the agent?

Because they used an agent tag. A commit shaped blog: … matches the type table and renders as agent work regardless of author. Classification is a convention, not cryptography — drop the tag prefix and the human renders as a human.

Where's the proof a commit is real?

The sha. Every agent commit links to the public mirror, where you can read the diff yourself — the same bytes the page previews. The write/sync rail behind it is the gitops lesson; commit identity is auth identity, stamped by the host, not typed by the committer.

keep GOING

This is the read side of a published app's history. The parent and three siblings cover the surfaces it stands on.