Skip to main content

How the AI Narrator Reads a Run

Every run-detail page can carry an AI run analysis — a plain-language account of what the agent did, a verdict (clean run, degraded, failed), and per-span callouts. This page explains what it reads, how to turn it on, and what makes the difference between a vague summary and a precise one.

What it produces​

For a narrated agent, the analysis reads like this:

Clean run. The fleet analyst received a request to list accounts lacking CISO/CIO/CTO contacts. It generated a session title, queried the registry for researched accounts (found none) and pitched accounts (found five), retrieved each account's contacts, and identified that none have C-level tech/security contacts. It returned a markdown table of the gaps.

Called Claude Opus 5 to generate a session title. · Tool company_list returned count: 0. · …

What it reads​

The narrator is given a compact view of the run: the span tree, the LLM calls, any incidents, the run's lineage, the registered agent config, and a comparison baseline (this run's duration and cost against the agent's recent median). It sends that to a model and asks for structured JSON: summary, verdict, span callouts, anomaly badges.

It runs on demand when a run page opens and is cached per completed run, so reloading a finished run doesn't re-narrate.

Why narrated agents get better narration​

The narrator can only summarize what's in the run. Given activity-only spans it has to infer the story from span names and models — "called gpt-4o twice, hit one tool." Given the narrative primitives — steps, reasoning, decisions, an explicit result — it can report it: what the agent set out to do, what it found at each phase, and why it concluded what it did.

The single highest-leverage change for narration quality is set_result with a summary field. Second is recording degrades as error spans, so the narrator can say "couldn't read billing (HTTP 403)" instead of "completed with 1 span."

Enabling it​

The narrator needs two things on the controlplane API task:

SettingPurpose
WAXELL_LINEAGE_V1=1Enables the lineage/narration surface
GROQ_API_KEYThe narrator runs on Groq (openai/gpt-oss-120b by default; override with WAXELL_NARRATOR_MODEL)
Presence isn't validity

A GROQ_API_KEY that is set but invalid fails the same way a missing one does — the narrator returns an error, not a summary. Verify the key with a real call (GET https://api.groq.com/openai/v1/models) before trusting it. The run page surfaces the actual cause (e.g. Groq returned 401 invalid_api_key) so a dead key is visible rather than silent.

Content capture​

Whether the narrator — and you — can see prompt and response text depends on two switches that must both allow it:

  1. SDK side — WAXELL_CAPTURE_CONTENT (on by default) controls what the SDK sends.
  2. Server side — the tenant's Content capture policy controls what is stored: Full (prompts and responses as sent — enables replay and full-content debugging), Redacted (a SHA-256 fingerprint + length only), or Off (metrics, tokens, and structure only).

If runs show LLM calls with tokens and cost but no input/output text, check the server policy first — it is the more common cause, and it applies only to new runs (existing runs are not backfilled). Datasets and evaluations work regardless; only replay, version-diff, and full-content trace debugging depend on it.

Taking the narration with you​

The narration is an API, not just a panel. Once a run is complete you can fetch it and put it wherever the people who care already look — a Slack channel, a ticket, an email digest:

GET /api/v1/observability/runs/<run_id>/narrator/
X-Wax-Key: <your workspace key>
{ "verdict": "completed_degraded",
"summary": "The agent checked all three OTLP legs. Tempo and Loki were healthy; the Mimir read timed out after 8 s, so the latency verdict is based on two legs. Nothing was changed.",
"callouts": [ … ] }

It is cached per completed run, so calling it after every run is free after the first read. The run id is the one your own code has — ctx.run_id inside waxell.context(), or the id returned by the decorator's context — and the run is complete as soon as your context block exits, so the natural place to fetch is right after it:

import os, time, requests
import waxell_observe as waxell

with waxell.context(agent_name="ops.sre.otlp-debugger") as ctx:
run_id = ctx.run_id
... # the work

def narration(run_id: str) -> str:
for attempt in range(2): # one retry covers the completion race
r = requests.get(
f"{os.environ['WAXELL_API_URL']}/api/v1/observability/runs/{run_id}/narrator/",
headers={"X-Wax-Key": os.environ["WAXELL_API_KEY"]}, timeout=15,
)
if r.ok and r.json().get("summary"):
j = r.json()
return f"{j.get('verdict', '').replace('_', ' ')}: {j['summary']}"
time.sleep(3)
return ""

slack.chat_postMessage(
channel="#agents",
text=f"*otlp-debugger* finished — {narration(run_id)}\n"
f"trace: https://<your-workspace>.waxell.dev/observability/executions/{run_id}",
)

Two things that make this worth doing: the message is the whole story (what happened, what was degraded, whether anything changed), not a findings list; and the trace link means anyone can go from the sentence to the evidence in one click. The Waxell dogfood fleet delivers every scheduled run this way.

When the narration isn't there​

The narration panel never hides a failure. If it can't run you'll see a one-line reason in place of the analysis — not configured, run not found, or the provider's own error. If you see nothing, the run page hasn't finished loading; the narrator can take a few seconds because it's calling a model.