Traces.
Every run leaves plain JSON: every question, every distribution, every branch not taken.
Everything on this page was produced by the real runtime: the haunted desk chain, run twice against a scripted Jev client at build time. The probabilities are hand-picked; the spans, decisions, summaries and diffs are exactly what jev.run returns.
Anatomy of a trace#
A trace is plain, versioned JSON. Save it, ship it to your logs, render it, diff it. Here's the top of the toaster run: My toaster whispers my name at 3am and the bread comes out cold.
{ "version": 1,1 "runId": "run_7f3a9c21e04b5d18", "chainId": "front-desk", "status": "ok", "startedAt": "2026-09-25T03:41:46.660Z",2 "input": "My toaster whispers my name at 3am and the bread comes out cold.", "usage": {3 "calls": 3, "requests": 3, "inputTokens": 364, "outputTokens": 5, "costUsd": 0.000015288 }, "models": ["jev-1.13.0"], "durationMs": 143.94, "output": "Try turning it off and on again. If it asks you not to, call us back.", "spans": "[ …5 spans ]"4}- 1
TRACE_VERSION, currently1. Bumped if the shape ever changes, so stored traces stay readable. - 2The only wall-clock time in the trace. Every other time (span
start/end, callstart/end, logat) is milliseconds since this moment, so traces are easy to lay on a timeline. - 3Totals for the run.
callscounts every Jev call a node made;requestscounts HTTP requests. They differ when batching merged several calls into one request. - 4One span per node visited, in start order. Nodes that never ran have no span. That absence is how you know a branch wasn't taken.
| name | type | what it does |
|---|---|---|
| runId, chainId | string | The run's id (pass runId to set it) and the root node's id. |
| status | RunStatus | "running" while streaming, then ok, halted, error or aborted. |
| input, output | Json | JSON-safe copies. Strings over maxTraceString (default 4000) are truncated; output is only set when status is ok. |
| durationMs | number | Wall time of the whole run. |
| usage | TraceUsage | calls, requests, inputTokens, outputTokens, costUsd. |
| models | string[] | Versioned models that actually answered, e.g. jev-1.13.0. |
| error | SerializedError? | Set when status is error or aborted. See Errors. |
| halted | { path, nodeId, summary }? | Set when a gate with no otherwise stopped the run. |
| name | type | what it does |
|---|---|---|
| path | string | Unique within the run: the edges from the root, e.g. $/paranormal/0/then. Ids are for humans and may repeat; paths never do. |
| parentPath, edge | string | null | Where it hangs in the tree, and the edge (a route label, then/otherwise, a step index…) that led here. |
| nodeId, kind, title | string | What ran. |
| status | SpanStatus | running, ok, halted or error. |
| start, end | number | Ms offsets from startedAt. |
| input, output | Json | What went in and what came out. |
| calls | JevCall[] | Every Jev call this node made: state, questions, answers, tokens, cost, latency, attempts, request id, batch info, and the tier for cascades. |
| decision | Decision? | For route, gate and cascade spans. See below. |
| retries, logs, error | … | Retries of Jev calls and step code, notes from ctx.log, and what went wrong. |
My toaster whispers my name at 3am and the bread comes out cold.
Decisions#
Route, gate and cascade spans carry a decision: the edge taken, every candidate edge with the number that decided it, the bar it was measured against, and one sentence explaining why. Here's the root span of the toaster run, trimmed a little:
{ "path": "$",1 "nodeId": "front-desk", "kind": "route", "status": "ok", "start": 1.65, "end": 143.92, "input": "My toaster whispers my name at 3a…", "calls": [ { "id": "call_1", "model": "jev-1.13.0", "state": "My toaster whispers my name at 3a…", "questions": {2 "decision": { "type": "choice", "instructions": "Which team should handle this app…", "criteria": { "repair": "…", "billing": "…", "paranormal": "…" } }, "sarcastic": { "type": "noul", "instructions": "Is the customer joking or being s…" }, "angry": { "type": "noul", "instructions": "Is the customer angry?" } }, "answers": {3 "decision": { "type": "choice", "choice": "paranormal", "probabilities": { "repair": 0.02, "billing": 0.004, "paranormal": 0.976 }, "confidence": 0.887 }, "sarcastic": { "type": "noul", "noul": 0.08 }, "angry": { "type": "noul", "noul": 0.11 } }, "inputTokens": 162, "outputTokens": 3, "costUsd": 0.000006804,4 "start": 2.12, "end": 43.5, "latencyMs": 38, "attempts": 1, "requestId": "req_5e1f00" } ], "decision": { "kind": "route", "question": "decision", "taken": "paranormal", "edges": [5 { "edge": "repair", "value": 0.02, "taken": false }, { "edge": "billing", "value": 0.004, "taken": false }, { "edge": "paranormal", "value": 0.976, "taken": true }, { "edge": "lowConfidence", "value": 0.887, "taken": false } ], "metric": "probability", "value": 0.976, "confidence": 0.887, "summary": "Went to \"paranormal\" with 98%, a landslide over \"repair\" at 2% (confidence 0.89)."6 }, "output": "Try turning it off and on again. …"}- 1The root is always
$. Children append their edge: this route's paranormal branch runs at$/paranormal. - 2The route's own question goes out under the reserved key
decision. ItsalsoAskquestions (sarcastic,angry) rode along in the same call, for free. - 3Full distributions, not just the winner. This is what makes a trace worth keeping.
- 4Input tokens × the client's price ($0.042 per million by default). When a request is batched, usage is split evenly across the calls that shared it.
- 5Every way out of this node, taken or not, with its deciding number.
lowConfidencescores the confidence (0.89), which cleared the 0.4 bar, so Jev's pick stood. - 6Templated from the numbers by
explainDecision. No LLM was harmed in the making of this sentence.
| name | type | what it does |
|---|---|---|
| kind | "route" | "gate" | "cascade" | Which kind of node decided. |
| taken | string | The edge taken: a label, then/otherwise/unsure/halt, lowConfidence, a tier id or fallback. |
| edges | EdgeScore[] | { edge, value, taken } for every candidate. Cascade tiers that never ran have value: null. |
| metric, value | Metric, number | What was measured (probability, noul, score or confidence) and its value for the taken edge. |
| threshold | { min?, max?, label? } | The bar, for gates and cascades. |
| confidence | number? | Jev's confidence in the answer (absent for noul gates). |
| fallback | boolean? | True when a route's lowConfidence path overrode the obvious answer. |
| summary | string | One plain-English sentence. |
explainTrace#
explainTrace(trace) returns a short story of the run: one line per decision, plus how it ended (halted, failed or aborted). Good for logs, Slack alerts and the bottom of a support ticket.
import { explainTrace } from "jevchain";
explainTrace(trace).forEach((line) => console.log(line));- Front desk: Went to "paranormal" with 98%, a landslide over "repair" at 2% (confidence 0.89).
- Anyone in danger?: Passed: p(yes) = 0.04, under the 0.50 ceiling easily (by 0.46).
- What are we dealing with?: Went to "possessed-firmware" with 74%, a comfortable win over "poltergeist" at 18% (confidence 0.33).
The wording scales with the margin: a lead of 0.6 or more is “a landslide”, then “a comfortable win”, “a clear lead”, “a narrow lead”, and under 0.05 “a photo finish” (marginWord). Gates say how far a value cleared or missed its bar.
diffTraces#
Same chain, two inputs, different endings. diffTraces(a, b) compares the paths they visited and names the first decision where they split. The microwave run (The microwave opened by itself, said 'soon', and now there is smoke coming out of it.) got through the front desk the same way, then failed the safety gate:
import { diffTraces } from "jevchain";
const diff = diffTraces(toaster, microwave);{
"shared": ["$", "$/paranormal", "$/paranormal/0"],
"onlyA": [
"$/paranormal/0/then",
"$/paranormal/0/then/possessed-firmware"
],
"onlyB": ["$/paranormal/0/otherwise"],
"divergedAt": {
"path": "$/paranormal/0",
"nodeId": "anyone-in-danger",
"a": "then",
"b": "otherwise"
}
}Use it to explain a regression (“why did yesterday's ticket go to billing?”), or in tests: pin a golden trace and assert that divergedAt is undefined.
graphOf and overlayTrace#
A chain is a tree, but it runs like a graph: routes fan out, parallels fork and join, cascades climb a ladder. graphOf(chain) compiles it into vertices and edges, adding the synthetic ones you'd want to draw: tier vertices for each cascade rung, a join after every parallel, and a halt for gates with no otherwise. Every edge that a decision picks carries decidedBy, pointing at the span and edge key that chose it.
overlayTrace(graph, trace) paints a run onto it: each vertex gets a state (idle, running, ok, error, halted, skipped) and each edge is taken or not-taken with its deciding number. It accepts partial traces, which is how the studio animates a run as events stream in. The two runs from above, drawn by this site's own map component:
import { graphOf, overlayTrace } from "jevchain";
const graph = graphOf(hauntedDesk); // { vertices, edges, entry }
const overlay = overlayTrace(graph, trace); // { vertices: {id → state}, edges: {id → state, value} }- calls jev
- your code
- leaf / emit
- fork / join