Skip to main content

Tell the Story of a Run

Auto-instrumentation tells you what happened: which model was called, how many tokens, what it cost. It does not tell you what it meant — what the agent set out to do, what it read, what it decided, what it concluded, and what it couldn't do.

That second layer is what lets someone open a run and understand it without clicking through every span. This series shows how to add it in each of the three integration modes.

Activity vs. narrative​

Compare two traces for the same agent.

Activity only — the default when you stop at init():

ops.sre.otlp-debugger.default      ✓  1.08s
api/internal/observability/health ✓ 101ms
chat kimi-k3 ✓ 2.8s 164 tok $0.0005

You can see it ran, read something, and called a model. You cannot see why, what it found, or what it decided.

With narrative — the same run after the agent records its story:

task     Flag OTLP/ingest pipeline legs that are down + the likely cause
step read observability health → collector/tempo/loki all healthy
decision leg_status → healthy (all 3 legs 2xx)
result OTLP pipeline healthy (collector/tempo/loki)
degraded —

Same spans underneath — but now the run reads as a sentence. The AI narrator also produces a far better summary, because it has the agent's own account to work from instead of inferring one from span names.

The narrative primitives​

These are the calls that carry the story. All of them live on the run's context; the first two also have module-level shortcuts that work anywhere a context is active.

What you want to sayCall
"I'm now doing this phase"ctx.record_step("read health", output={...}) — or waxell.step("read health", {...})
"Here's why I'm about to do X"ctx.record_reasoning(...) — or waxell.reason(step, thought, evidence, conclusion)
"I chose A over B/C because…"ctx.record_decision(name=..., options=[...], chosen=..., reasoning=...)
"This is my conclusion"ctx.set_result({...}) — the result attached when the run completes
"This is what I told the user"ctx.record_agent_response(text) (the decorator does this for you from the return value)
"I couldn't do this, here's why"ctx.record_tool_call(name=..., status="error", error="...") — see Recording what degraded
waxell.step() and waxell.reason() need an active context

The module-level shortcuts look up the current run context. Outside a decorator or a with waxell.context() block they silently do nothing. That is why narrative is impossible in pure auto-instrumentation mode — there is no run to attach the story to. See the mode comparison below.

Which mode can carry a story?​

ModeGets automaticallyNarrative primitivesVerdict
Auto-instrumentation — init() onlyLLM spans, tokens, cost, latency, buffered into auto:{model} runsNone — no context exists, so step()/reason() no-op and there is no set_resultActivity only
Decorator — @observeA run per call, inputs, the return value as agent_response, success/errorwaxell.step() / waxell.reason() work inside the function; set_result / record_decision via the injected waxell_ctxFull story with a few calls
Context manager — with waxell.context()A run with explicit lifecycleEverything, explicitlyFull story, full control

The practical rule: init() is where you start; a story needs a decorator or a context. Adding @observe to your entry function is usually the whole migration.

Recording what degraded​

The most important sentence in many runs is "I couldn't read my data source" — and it is the one most often missing. If a read fails and you simply return early, the trace is silent about the single fact that explains everything.

Record it as a failed tool call so it shows up as a red span in the trace, with the reason:

payload = client.get("internal/billing/stats/")
if payload is None:
ctx.record_tool_call(
name="internal/billing/stats",
status="error",
error=client.last_error, # e.g. "HTTP 403" / "no admin token"
tool_type="api",
)
ctx.set_result({"summary": f"could not read billing ({client.last_error})"})
return

Do the same when a model provider is unavailable, so a run that fell back to a stub does not look like a clean zero-token success:

if not real:
ctx.record_tool_call(name=f"llm.{provider}", status="error",
error=f"{provider} unavailable (no key or provider degraded)")

The rule of thumb: every early return should leave a span behind. A reader should never have to guess why a run was short.

Built in for reads that never happen

A read that is skipped — not attempted because the credential it needs is missing — is the easiest one to lose, because there's no HTTP call to instrument. The pattern above covers it: record it as an error span with a skipped: … reason at the point you decide not to call. (The Waxell fleet's platform client does exactly this automatically, so a run that couldn't read its data source always shows why.)

Run status is three-state, not two​

A run that finished but couldn't do part of its job is neither a success nor a failure. Waxell records it as degraded:

StatusMeaning
successcompleted, and everything it set out to read/call worked
degradedcompleted, but it reported degrade reasons — a read it couldn't reach, a provider that stubbed, a library that wasn't installed
failedraised, or was blocked by governance

You get degraded for free by putting your degrade reasons in the result — ctx.set_result({"summary": ..., "degraded": ["read:HTTP 403", "llm:gemini"]}) — which is exactly what the pattern above produces. The run list, the run page, the fleet's Mission Control, and wax runs list --status degraded all surface it in amber, so a run that quietly fell back never wears a green check again.

The walkthroughs​

Each one starts from the mode's own quickstart and ends with a fully narrated run you can open in the UI:

  1. Auto-instrumentation — and why it can't tell a story
  2. Decorator — narrate with three lines
  3. Context manager — full control, including degrades
  4. How the AI narrator reads a run