Skip to content

Add a browser operator console to the durable-agent demo - #718

Open
7nohe wants to merge 4 commits into
mainfrom
feat/agents-demo-console
Open

Add a browser operator console to the durable-agent demo#718
7nohe wants to merge 4 commits into
mainfrom
feat/agents-demo-console

Conversation

@7nohe

@7nohe 7nohe commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

examples/agents demonstrates the RFC 0017 durable agent runtime, but it was
API-only: the approval loop could only be driven with curl and a bearer token.
It now also serves an Inertia console that shows the tickets, the pending
approvals and the agent's own report on one page, with Approve, Reject and
Run sweep.

The JSON surface is unchanged

Routes carrying .agent() metadata keep returning JSON — guren check warns
about an agent route answering with an Inertia response, because a rendered page
is not a tool result. tool:list output is byte-identical to main.

The console gets its own actions under /console/*, so the bare /approvals/...
paths the README's curl walkthrough uses keep working. Both surfaces answer
approvals through one shared module (app/Services/approvals.ts): a second copy
of "which rows are answerable" is the copy that hands the agent a grant a human
gave once. Only presentation differs — the console redirects and flashes the
refusal, the API returns a status code.

Login and sessions

Login spends the same bearer token the API takes, verified with verifyApiToken.
users carries no password column, and scrypt in a request path does not fit the
Workers Free CPU budget (10 ms per invocation against a measured 20-31 ms cold
boot). SessionGuard.login() rotates the session id, so a pre-auth session
cannot be fixated.

Sessions use DatabaseSessionStore over a new sessions table: on Workers the
login redirect and the page it lands on are answered by different isolates, so
per-isolate memory would drop the session.

cookieSecure cannot be decided by NODE_ENV here — wrangler.jsonc defines it
to "production" at bundle time, local or not. The app reads its own
TRIAGER_INSECURE_COOKIES, through getters, because wrangler vars land after
the module evaluates.

Route wiring and the CLI flags

routes/web.ts is now the app's single entry registrar and calls
registerApiRoutes(router), which is what lets guren check reach routes/api.ts
from the entry createApp({ routes }) names. Consequently:

  • audit dropped its --routes routes/api.ts flag and now covers 31 checks
    instead of 23
    — the console's six new routes are the new attack surface.
  • codegen was repointed to routes/web.ts; it needs the whole graph.
  • check and tool:list carried no flag and still need none.

CSRF

No configuration was needed in either direction. createApp({ auth }) mounts CSRF
over the whole app; the console carries a session cookie and is verified, while a
bearer request that carries no cookies is exempt on its own terms, as is a request
carrying the agent principal the pipeline installed. The one visible change: a
mutating request with neither a bearer header nor a CSRF token now answers 403
rather than 401, because CSRF runs ahead of the auth middleware.

Free-plan budget

One console render costs 6 D1 queries (7 when the session is written back rather
than touched), against the 50-per-invocation Free-plan ceiling. Ticket and
approval listings are bounded at 40 / 50 / 20 rows.

Also fixed

resolveApproval derived its refusal status without the pending-only guard that
presentApproval has, so an approval that was answered and then passed its window
was refused as "expired before it was answered". Both now share one statusAt().
The HTTP status was, and stays, 409.

Scope

examples/agents/** and docs/** only — nothing under packages/, so no
changeset and nothing to release.

Verification

  • Root: build, typecheck, lint, audit:core-first, audit:docs — all green.
  • examples/agents: test 27 pass, check 15 passed / 6 warnings / 0 failures,
    audit 31 passed / 1 pre-existing advisory warning / 0 failures.
  • wrangler dev --local, full loop: sign in, Run sweep, two approvals appear,
    Approve one and Reject the other, wait past the ledger's first backoff without
    sweeping, then the approved ticket closes by alarm and the rejected one appears
    under declined. examples/agents/console.png is that state.
  • bun run dev: sign in and console render re-confirmed after the final
    simplification pass, which was separately checked to leave the rendered markup
    unchanged across populated, empty and no-sweep prop shapes.

examples/agents was API-only: the RFC 0017 approval loop could only be
driven with curl and a bearer token. It now also serves an Inertia console
showing the tickets, the pending approvals and the agent's own report on
one page, with Approve, Reject and Run sweep.

The JSON surface is unchanged. Routes carrying .agent() metadata keep
returning JSON, since guren check warns about an agent route answering
with an Inertia response, and tool:list output is byte-identical. The two
operator surfaces answer approvals through one shared module, so a second
copy of "which rows are answerable" cannot hand the agent a grant a human
gave once; only presentation differs.

Login spends the same bearer token the API takes: users carries no
password column, and scrypt in a request path does not fit the Workers
Free CPU budget. Sessions are database-backed because a Worker answers
the login redirect and the page it lands on from different isolates.
The console shipped its own 141-line stylesheet with invented colours and a
hardcoded dark scheme. It now imports the same guren.css token sheet a
scaffolded app gets, styles with the g-* Tailwind utilities over it, and
follows the reader's light/dark preference.

The system's rules are the point: one crimson fill per screen, spent on Run
sweep, with Sign out, Approve and Reject as outlines; signal tokens for the
status chips; machine-issued values in mono; the ember tick once, on the
page title. Login reuses the class spellings the generators emit.

The token sheet now has three copies in the repo, so the byte-identity test
covers all of them. An inline row no longer wraps, which kept a long
ticket title's status chip off its own line.
`.guren/pages.gen.ts` is gitignored, and the demo's controllers and Inertia
entry import it now that the app renders pages. CI's only codegen step ran
the blog example, so `typecheck:example-agents` failed on a fresh checkout
with TS2307 while passing in any tree where the demo had been run.

`build:routes` now covers both examples, which is also what makes a clean
clone's `bun run typecheck` reproducible.
The approval title fragment was spelled out in both approval panels and the
disabled tail on all four buttons; both are now one name. The alert tone map
moves beside the chip one at module scope rather than being rebuilt per
render, and a single-use outline-button constant that named only one of the
screen's three outline buttons is inlined.

Rendered markup is byte-identical across populated, empty and no-sweep
shapes, which is what keeps console.png accurate.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant