Skip to content

See your runs

Record every run to a folder with one line, then open a local page that shows it: the flow graph, a timeline of parallel calls, costs, cache hits, retries and pending approvals. It works with any framework and with plain code, offline, with no extra package. The same page can also chat with your agent.

flowchart LR
  subgraph app["your processes (script, FastAPI workers, Celery workers)"]
    E["Executor"] --> R["record(): Recorder sink"]
  end
  R -- "append, one file per run and process" --> F[(".agent_adapter/runs/*.jsonl")]
  F -- "read every second" --> V["agent-adapter ui<br/>127.0.0.1 only"]
  V --> B["browser: runs · graph · timeline · tools"]
  V -- "--export" --> H["report.html (offline)"]
  V <-. "--approvals: decide only" .-> DB[("approvals.db")]
  DB -. "executor.resume(approval_id)" .-> E

1. Record

record() adds a recorder next to the executor's sinks; it never replaces them. Every context, executor.model(...) wrapper, FlowRunner and compiled flow of that executor reaches it, even one made before the call. With no executor it follows the default one, also after aa.configure(...) replaces it.

record()  # the default executor: aa.call(...) and integrations given no executor=
aa.configure(registry)  # before or after: record() follows the default executor
App Where the one line goes
plain script at the top, as above
FastAPI in the lifespan, on the executor you keep in app.state
Celery, RQ in the per-process executor factory
LangGraph or any framework on the executor you pass as executor=
@asynccontextmanager
async def lifespan(app: Any) -> AsyncIterator[None]:
    """FastAPI lifespan: one executor per worker process, recorded."""
    executor = Executor(registry)
    record(executor, source="fastapi")  # each worker writes its own files
    app.state.executor = executor
    yield
@functools.cache
def worker_executor() -> Executor:
    """One per Celery worker process, built after the prefork fork."""
    executor = Executor(registry)
    record(executor, source="celery")
    return executor
executor = Executor(registry)
record(executor, source="langgraph")  # name your framework or backend
# then hand it over: attach("langgraph", registry, executor=executor),
# tool_node(registry, executor=executor), compile_flow(flow, executor=executor), ...

record(executor=None, *, folder=".agent_adapter/runs", capture_content=False, keep_runs=200, source="") returns the Recorder. A second call for the same executor and folder returns the same recorder; other options for it are a ConfigError. source names the framework or backend in the runs list. Writes run on a background thread; queued events are written at exit, or now with recorder.flush(). recorder.stop() detaches it from the executor (or the default), writes what is queued and ignores later events; executor.remove_sink(sink) undoes any add_sink.

2. Open the viewer

agent-adapter ui                                  # .agent_adapter/runs on port 8780
agent-adapter ui path/to/runs --port 9000
agent-adapter ui runs --approvals approvals.db --reviewer ana
agent-adapter ui runs --export report.html        # one offline file, then exit
agent-adapter ui runs --export one.html --run KEY # one run only
agent-adapter ui runs --import traces.json        # add OTLP JSON traces as runs, then serve

It prints http://127.0.0.1:PORT/#token=...: open that address. New runs from any process on the machine appear within about a second. A big run loads in pages; the timeline shows every flow and node, then the latest 5,000 calls.

What the page shows

Part Shows
Runs time, status (ok, failed, paused, running, unfinished), duration, cost, framework or backend, tags; a search box and a tag filter (Search and tag runs); a call made outside run_scope is a run that ends with its last call
Flow graph the Flow a FlowRunner ran, with live status per node (stopped ■ for one cancelled when its flow or run ended): zoom, pan, minimap, search, replay, node panel (Navigate the graph)
Timeline one lane per concurrent call; retries, timeouts, cache hits, cost, context.compressed tokens before → after, error kinds
Events, State, Compare every event, filterable, with its raw JSON; each node's changed fields (values when traced); two runs side by side
Detail the recorded data of each event of the clicked call
Tools the registry when the run started: schemas, confirm, cache, effects, timeouts, retries
Approvals with --approvals only: pending records, Approve and Deny

A run with no Flow shows the timeline only. Flows compiled to LangGraph or ADK show the timeline and record changed field names only; flow.to_mermaid() draws their graph.

What is written

Rule Detail
layout <start ms>.<run id>.<pid>.jsonl; each process writes only its own files, so many workers can share one folder
content the keys arguments, result, input_messages, output_messages only with capture_content=True, as with OTelSink; other event data (your custom.* events, integration extras) is written with secret masking only
flow state node.finished names the changed fields; with FlowRunner(trace_state=True) their values are kept too, even without capture_content (secret-named keys masked, 2 KB each)
secrets values under secret names (api_key, password, access_token) are always ***
size strings over 2,000 characters are cut; a run stops at 50,000 events with one truncated line
rotation past keep_runs runs, the oldest ended ones are deleted; a run any process wrote to in the last 15 minutes and has not ended is kept
failures a write error or a full queue (10,000 events) is logged as a WARNING and never reaches your call

Approvals from the page

The page only records a decision in the SQLite store, with decided_by set to --reviewer. It never runs a tool. Your app's own executor.resume(approval_id) then runs the call once, as in Resume a parked call. The page does not decide flow-node approvals (use FlowRunner.resume) or records the HTTP tool server parked (it re-checks the caller itself).

Security

Guard Why
binds 127.0.0.1 only, exclusively; any other --host is refused run data never leaves the machine; no second program shares the port
an access token, new on every start, after # in the printed address; the page moves it to this tab's sessionStorage, removes it from the address bar, and sends it as X-AA-Token on every data request; no cookie other users and programs on the machine get no data without the address; the token never reaches a server log, a Referer or another port
Host header must be 127.0.0.1:PORT or localhost:PORT blocks DNS rebinding
decide and tag: POST only, JSON body, Origin of this page, a CSRF token new on every start another site cannot decide or tag for you
a decided record is refused (409) each decision is made once
strict Content-Security-Policy; data shown as text, never as HTML recorded data cannot run script
--export file: data inline, no external URL, CSP default-src 'none' safe to open offline

Anyone who has the printed address can read every run and, with --approvals, approve or deny as --reviewer: keep it private, and stop the viewer when you are done (the address may stay in the browser's history; it stops working with the viewer). Anyone who can read the runs folder sees the runs too. Keep capture_content=False where prompts or results hold personal data.

Try it

python examples/viewer_fan_out.py
agent-adapter ui .agent_adapter/runs --approvals .agent_adapter/approvals.db --reviewer you

examples/viewer_fan_out.py searches a help center: a fan-out over three sources, a join, one loop that hits the cache, a compressed model call, one parallel batch and one confirm=True tool waiting for approval.

Files

File Does
agent_adapter/viewer/recorder.py Recorder sink (queue and writer thread) and record()
agent_adapter/viewer/_segments.py the lines of each run file; inferred ends of plain calls
agent_adapter/viewer/_files.py, _scrub.py file names, appends, rotation; content and secret rules
agent_adapter/viewer/runs.py, _fold.py reads the folder from where it stopped; summaries folded line by line, paged details
agent_adapter/exec/_sink_hub.py the executor's stable sink, and flow graphs for graph-taking sinks
agent_adapter/viewer/lanes.py, layout.py timeline spans and lanes; graph layers
agent_adapter/viewer/server.py, approvals.py the loopback server and the approval desk
agent_adapter/viewer/export.py, assets.py, static/ the page, and the one-file export
agent_adapter/flow/diagram.py Flow.graph() and Flow.to_mermaid()