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
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.
| half | what it is | where it comes from |
|---|---|---|
changes | last 30 commits, newest first — {sha, ts, author, msg} each | Workbooks.Git.log_entries |
agent | the keeper's live status — active, running, last run, next run, lifecycle | Workbooks.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
| token | field | what the page does with it |
|---|---|---|
%h | sha | links to the public mirror — the proof you can click |
%ct | ts | renders relative time — Ns / Nm / Nh / Nd ago |
%an | author | fallback attribution when the message has no tag |
%s | msg | classified 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:
| tag | who | color |
|---|---|---|
add | agent | live green |
rem | agent | #b48cff |
compose | agent | #4dd6c4 |
strategy | agent | #ff9e7a |
blog · tweet | agent | #5aa7ff |
audit | agent | amber |
plan · planning · keeper | agent | faint |
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
section — blog 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.jsonlis 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
agenthalf of the feed is the keeper's status — no keeper running, no agent block. Thechangeshalf 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.