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 say | Call |
|---|---|
| "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 contextThe 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?
| Mode | Gets automatically | Narrative primitives | Verdict |
|---|---|---|---|
Auto-instrumentation — init() only | LLM spans, tokens, cost, latency, buffered into auto:{model} runs | None — no context exists, so step()/reason() no-op and there is no set_result | Activity only |
Decorator — @observe | A run per call, inputs, the return value as agent_response, success/error | waxell.step() / waxell.reason() work inside the function; set_result / record_decision via the injected waxell_ctx | Full story with a few calls |
Context manager — with waxell.context() | A run with explicit lifecycle | Everything, explicitly | Full 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.
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:
| Status | Meaning |
|---|---|
| success | completed, and everything it set out to read/call worked |
| degraded | completed, but it reported degrade reasons — a read it couldn't reach, a provider that stubbed, a library that wasn't installed |
| failed | raised, 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: