skip to content
docs / running & inspecting

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.

trace.json
{
  "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. 1
    TRACE_VERSION, currently 1. Bumped if the shape ever changes, so stored traces stay readable.
  2. 2
    The only wall-clock time in the trace. Every other time (span start/end, call start/end, log at) is milliseconds since this moment, so traces are easy to lay on a timeline.
  3. 3
    Totals for the run. calls counts every Jev call a node made; requests counts HTTP requests. They differ when batching merged several calls into one request.
  4. 4
    One 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.
Trace
runId, chainIdstringThe run's id (pass runId to set it) and the root node's id.
statusRunStatus"running" while streaming, then ok, halted, error or aborted.
input, outputJsonJSON-safe copies. Strings over maxTraceString (default 4000) are truncated; output is only set when status is ok.
durationMsnumberWall time of the whole run.
usageTraceUsagecalls, requests, inputTokens, outputTokens, costUsd.
modelsstring[]Versioned models that actually answered, e.g. jev-1.13.0.
errorSerializedError?Set when status is error or aborted. See Errors.
halted{ path, nodeId, summary }?Set when a gate with no otherwise stopped the run.
Span
pathstringUnique within the run: the edges from the root, e.g. $/paranormal/0/then. Ids are for humans and may repeat; paths never do.
parentPath, edgestring | nullWhere it hangs in the tree, and the edge (a route label, then/otherwise, a step index…) that led here.
nodeId, kind, titlestringWhat ran.
statusSpanStatusrunning, ok, halted or error.
start, endnumberMs offsets from startedAt.
input, outputJsonWhat went in and what came out.
callsJevCall[]Every Jev call this node made: state, questions, answers, tokens, cost, latency, attempts, request id, batch info, and the tier for cascades.
decisionDecision?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.
▶ run itHaunted Appliance Support Deskgallerysource →
The chain these traces came from. Run it in the studio and you'll get the same shape, only with real probabilities.
route · front-deskrouteFront deskemit · book-technicianemitbook-technicianemit · forward-billingemitforward-billinggate · anyone-in-dangergateAnyone in danger?route · classify-entityrouteWhat are we dealing with?emit · book-exorcistemitbook-exorcistemit · power-cycleemitpower-cycleemit · close-windowemitclose-windowemit · evacuateemitevacuateemit · ask-daveemitask-daverepairbillingpoltergeistpossessed-firmwarejust-a-draftthenotherwiseparanormalunsure

My toaster whispers my name at 3am and the bread comes out cold.

open in studio →

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:

trace.spans[0]
{
  "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. …"
}
  1. 1
    The root is always $. Children append their edge: this route's paranormal branch runs at $/paranormal.
  2. 2
    The route's own question goes out under the reserved key decision. Its alsoAsk questions (sarcastic, angry) rode along in the same call, for free.
  3. 3
    Full distributions, not just the winner. This is what makes a trace worth keeping.
  4. 4
    Input 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.
  5. 5
    Every way out of this node, taken or not, with its deciding number. lowConfidence scores the confidence (0.89), which cleared the 0.4 bar, so Jev's pick stood.
  6. 6
    Templated from the numbers by explainDecision. No LLM was harmed in the making of this sentence.
Decision
kind"route" | "gate" | "cascade"Which kind of node decided.
takenstringThe edge taken: a label, then/otherwise/unsure/halt, lowConfidence, a tier id or fallback.
edgesEdgeScore[]{ edge, value, taken } for every candidate. Cascade tiers that never ran have value: null.
metric, valueMetric, numberWhat was measured (probability, noul, score or confidence) and its value for the taken edge.
threshold{ min?, max?, label? }The bar, for gates and cascades.
confidencenumber?Jev's confidence in the answer (absent for noul gates).
fallbackboolean?True when a route's lowConfidence path overrode the obvious answer.
summarystringOne 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.

explain.ts
import { explainTrace } from "jevchain";

explainTrace(trace).forEach((line) => console.log(line));
stdout · the toaster run
  1. Front desk: Went to "paranormal" with 98%, a landslide over "repair" at 2% (confidence 0.89).
  2. Anyone in danger?: Passed: p(yes) = 0.04, under the 0.50 ceiling easily (by 0.46).
  3. 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:

diff.ts
import { diffTraces } from "jevchain";

const diff = diffTraces(toaster, microwave);
result
{
  "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:

draw.ts
import { graphOf, overlayTrace } from "jevchain";

const graph = graphOf(hauntedDesk);           // { vertices, edges, entry }
const overlay = overlayTrace(graph, trace);    // { vertices: {id → state}, edges: {id → state, value} }
toaster · possessed firmware3 calls · 144ms
route · front-deskrouteFront deskemit · book-technicianemitbook-technicianemit · forward-billingemitforward-billinggate · anyone-in-dangergateAnyone in danger?route · classify-entityrouteWhat are we dealing with?emit · book-exorcistemitbook-exorcistemit · power-cycleemitpower-cycleemit · close-windowemitclose-windowemit · evacuateemitevacuateemit · ask-daveemitask-daverepairbillingpoltergeistpossessed-firmwarejust-a-draftthenotherwiseparanormalunsure
microwave · evacuate2 calls · 113ms
route · front-deskrouteFront deskemit · book-technicianemitbook-technicianemit · forward-billingemitforward-billinggate · anyone-in-dangergateAnyone in danger?route · classify-entityrouteWhat are we dealing with?emit · book-exorcistemitbook-exorcistemit · power-cycleemitpower-cycleemit · close-windowemitclose-windowemit · evacuateemitevacuateemit · ask-daveemitask-daverepairbillingpoltergeistpossessed-firmwarejust-a-draftthenotherwiseparanormalunsure
  • calls jev
  • your code
  • leaf / emit
  • fork / join

api key

bring your own typesafe key, or ride the shared one (rate-limited, be nice).

shared key
checking…
your key
not set

your key stays in this browser (localStorage, jevchain.byok). it only travels to this site's /api/jev proxy in an x-typesafe-key header, which forwards it to typesafe and immediately forgets it. nothing is logged or stored server-side. requests on your own key get a much roomier rate limit.

keyboard shortcuts

fewer clicks, more chains. these work anywhere outside a text field.