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 seeWhat 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.

09:25:34 [controls] thread.send → 201
09:25:37 [ai] classify: reply (0.92)
09:25:38 [error] API 400: oneOf not supported in structured output
09:25:38 [ai] no suggestion posted

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.