observability
See what your
app is doing.
Your server-side pipeline has ten stages. The symptom is always "it didn't work." Without observability, you can't tell which stage broke. Controls expose every UI state machine as a typed, invocable API, so repld makes the whole chain visible.
One notification
replaces an hour of guessing.
A chat app with an AI pipeline: message in, classify, suggest, insert, deliver, render. Each notification pinpoints exactly where the chain broke.
| What you see | What failed |
|---|---|
| No [ai] classify notification | Dispatcher didn’t fire — duplicate event, self-skip wrong, or HMR double-mount |
| [ai] classify: none | Classifier rejected — message wasn’t actionable. Not a bug. |
| [ai] classify but no [ai] suggest | Confidence too low, or API error on the suggestion call |
| [error] API 400: schema invalid | Schema mismatch — exact error message, exact field path |
| [ai] posted but no card | SSE delivery or client-side parse/render issue |
| Card appears but ai.count wrong | UI state bug — managed flag, dedup, or reactivity failure |
Without this, "cards don't appear sometimes" means days of guessing. With it, you get ZodError at events.4.senderId and fix it in minutes.
The timeline arrives in order.
Multiple channel notifications give you a causal chain. You don't search logs — the cause-effect is obvious.
oneOf not supported in structured output The error arrived before the page froze. Page-hang bugs push to the channel the instant they happen, not when someone finally notices.
Your app
becomes an API.
A control is one of your app's actual UI state machines, exposed for the agent to drive.
Each one has typed actions and observable properties.
browser_controls discovers them.
browser_invoke runs them.
# browser_controls → window.controls.describeAll()
auth:
actions:
login(email, password, partyId) → void
logout() → void
properties:
user → { id, email, role } | null
partyId → string | null
thread:
actions:
send(message, threadId?) → { status: 201 }
goto(threadId) → void
properties:
threads → string[] # all thread IDs
active → string | null
ai:
actions:
accept() → void
dismiss() → void
properties:
count → number # pending suggestions
lastClassification → string | null Every action
records its own diff.
browser_invoke runs the full observation pipeline: settle, accessibility
tree, network delta, console delta. Plus
stateBefore / stateAfter /
duration on every call.
# browser_invoke(target, "thread", "send", {message: "test"})
{
"returned": { "status": 201 },
"stateBefore": { "threads": ["a1", "b2"], "active": "a1" },
"stateAfter": { "threads": ["a1", "b2"], "active": "a1" },
"duration": 142
}
# + observation pipeline: tree Δ + network Δ + console Δ End-to-end
in one turn.
Invoke via controls. Watch the pipeline stages arrive as channel notifications. Verify the result with controls again, or with a direct DB query. The full loop without leaving the conversation.
# the full loop — send, watch, verify
controls.invoke("thread", "send", {message: "test"})
# → [controls] thread.send → 201 (142ms)
# channel pushes arrive in real-time:
# → [ai] classify: reply (0.92)
# → [ai] posted suggestion in branch 6107
controls.invoke("ai", "count")
# → 1
psql("SELECT sender_id FROM events ORDER BY id DESC LIMIT 1")
# → AI_USER_ID ✓ From reactive to proactive.
Today, controls tell you what broke and where. The infrastructure is in place to tell you that something broke, before you notice.
Structured assertions
Every invocation records stateBefore, stateAfter,
duration. Assert on the transitions. Count went up after dismiss?
Action took 3s instead of 200ms? State bug, caught immediately.
Living health checks
A @every ticker that sends a message, waits for the pipeline notification,
and alerts if it doesn't arrive. A continuous smoke test that runs while you work, instead of
a suite you remember to run before deploy.
Pre-merge verification
Run the controls sequence against the dev server before pushing. If the pipeline still produces correct results after your change, ship it. Controls are the test API.
@every(300)
async def smoke():
tab = await browser.get("localhost:3000")
await tab.invoke("thread", "send", {"message": "health check"})
# wait for pipeline notification
await asyncio.sleep(5)
schema = await tab.controls()
if schema["ai"]["properties"]["count"]["value"] == 0:
notify("smoke FAIL: no suggestion after 5s", kind="alert") The progression: manual inspection → programmable controls → observation bridge → notification-driven development. Each session builds on the previous one's infrastructure. Controls aren't test tooling, they're how the UI state is already structured, and repld makes it observable.