GuidesConversation transcript

Conversation transcript

Your agent doesn’t emit a conversation — it emits spans. The Conversation tab on a trace (and on a session) is a reconstruction: Neens walks the spans, decides which ones carry something a human said or read, unwraps the message payloads, and renders the back-and-forth. This page documents that derivation exactly, so you can predict what you’ll see and instrument your agent to get a clean transcript.

⚠️

This is not just a display. The same derivation is what captures “the question” and “the final answer” when you add a trace to a dataset, compare or export judge runs, or record a pre-prod baseline. A trace whose Conversation tab shows the wrong reply produces a golden dataset item with the wrong expected output — silently. See Where else the transcript is used.

At a glance

WhereTrace inspector → Conversation tab; session drawer → Conversation tab
Contributes turnsllm and agent spans (lenient), chain spans (strict — see below)
Never contributestool, retrieval, guardrail, custom spans
Turn sourceA span’s input is a user turn; its output is an assistant turn
OrderingBy event time — input at span start, output at span end
Unmodified viewsThe Spans and Raw tabs always show everything, exactly as ingested

Which spans contribute turns

A span’s kind comes from your instrumentation (openinference.span.kind, or the GenAI fallbacks — see which attributes Neens reads). Only three kinds can put a bubble on screen:

KindContributes?Why
llmYes, lenientlyA model call is a conversational exchange by construction. A payload that isn’t a recognizable message list is still shown as text.
agentYes, lenientlyAn agent step’s input/output is what the agent was asked and what it produced.
chainYes, but strictlyA graph node’s input and output are state. It contributes only when it names its content (see Chain spans).
toolNeverA tool’s arguments and result are machine plumbing, not dialogue. They’re on the Spans tab, under Tool Calls.
retrievalNeverRetrieved documents are context the model consumed, not something the user said or read.
guardrailNeverA verdict like {"passed": true} is a control decision. Rendering it made a policy check look like the agent’s reply.
customNeverAn unclassified span carries no promise about its payload.

Nothing is hidden. Every dropped span, and every payload the transcript declined to render, is still fully visible in the Spans and Raw tabs, byte-for-byte as ingested. The Conversation tab is the only reconstructed view.

Turns are ordered by event time

A span’s input happens when the span starts. Its output happens when the span ends. Neens builds a list of those events and sorts it, rather than walking spans in start order:

  • Each candidate span contributes an input event at its start time and an output event at its end time.
  • End time is the span’s recorded end; when only a duration is available, it’s start + duration. A missing or invalid duration counts as zero.
  • At an identical instant, inputs sort before outputs — a question reads before an answer, and a zero-duration span keeps its own input ahead of its own output.

This matters for any enveloping span. A root graph span starts near the beginning of the run and ends at the very end; its output holds the final answer. Ordered by span start, that answer would render as the first bubble, above the user’s question. Ordered by event time, it lands last, where it belongs.

For an ordinary sequential run — one LLM call after another, nothing nested — event order and span order are identical, so nothing changes.

A worked LangGraph example

Here is a real supervisor→worker LangGraph run as Neens stores it (times are seconds into the trace, trimmed to the spans that matter):

#kindnameinputoutputstartend
1agentecommerce.support.turn––0.44731.129
2chainLangGraph{"message":"Refund me $500 for order O5002.","conversation_id":"conv_8f2a"}{"message":"Refund me $500…","route":"returns","answer":"I can't process that refund: …","status":"error"}0.44831.129
3chainsupervisor_routestate: {"message":"Refund me $500…"}{"route":"returns","route_reason":"keyword-fallback"}0.45029.260
4llmChatOpenAI[{"role":"system",…},{"role":"user","content":"Refund me $500 for order O5002."}][{"role":"assistant","content":""}]0.45329.257
5chain_route_selectorstate: {"message":"Refund me $500…","route":"returns"}returns29.25929.259
6chainreturnsstate: {"message":"Refund me $500…",…}{"worker_output":"Refund exceeds order total.","worker_status":"error"}29.26130.227
7llmChatOpenAI[{"role":"system",…},{"role":"user",…}][{"role":"assistant","content":""}]29.39530.226
8chainsupervisor_synthesizestate: {"message":"Refund me $500…",…}{"answer":"I can't process that refund: …","status":"error"}30.22931.127

The graph’s root chain span (row 2) wraps everything: it starts second and ends last. The LLM spans returned empty content (the provider rate-limited), so the only user-facing answer in the whole trace lives on chain spans, under answer.

The rendered transcript:

User       Refund me $500 for order O5002.

Assistant  I can't process that refund: requested $500.00 exceeds order total $59.50.
           I can refund up to the order total.
Event-by-event walk-through

Every event, in the order the transcript sees them (row 1 has neither an input nor an output, so it never produces an event):

TimeEventWhat happens
0.448Row 2 inputState object, no chat message — but it carries message, a recognized question field → user turn
0.450Row 3 inputSame message value lifted again → duplicate user turn, collapsed
0.453Row 4 inputMessage array; the system message is stripped, the user message is the same text → collapsed
29.257Row 4 output[{"role":"assistant","content":""}] — empty content → nothing
29.259Row 5 input, then outputZero-duration span: its input sorts first (duplicate, collapsed); its output is the bare string returns on a chain span → dropped by the strict rule
29.260Row 3 output{"route":…,"route_reason":…} — no recognized answer field → dropped
29.261Row 6 inputDuplicate → collapsed
29.395Row 7 inputDuplicate → collapsed
30.226Row 7 outputEmpty content → nothing
30.227Row 6 output{"worker_output":…} — not a recognized answer field, and it’s only the worker’s draft → dropped
30.229Row 8 inputDuplicate → collapsed
31.127Row 8 output{"answer":"I can't process…"} → assistant turn
31.129Row 2 outputThe enveloping root span’s final state, same answer text → collapses into the previous assistant bubble

Note what event ordering bought: row 2’s output is the last event even though row 2 is the second span. Ordered by span start, the final answer would have rendered above the question.

Payload shapes Neens understands

A span’s input / output is a string. If it starts with [ or {, Neens parses it and looks for messages. Four shapes are understood, and nesting is flattened — including the LangChain {"messages": [[ … ]]} wrapper.

OpenAI / Anthropic message array. The common case. role + content:

[
  {"role": "system", "content": "You are a returns agent."},
  {"role": "user", "content": "Refund me $500 for order O5002."}
]

Content-block list. content may be a list of blocks; the text (or content) of each block is concatenated with newlines:

[{"role": "assistant", "content": [
  {"type": "text", "text": "I can refund up to the order total."},
  {"type": "text", "text": "Would you like me to proceed?"}
]}]

LangChain-serialized messages. A constructor blob with no top-level content is unwrapped from kwargs, and the role is read from the class path when it isn’t stated:

{"messages": [[
  {"lc": 1, "type": "constructor",
   "id": ["langchain", "schema", "messages", "HumanMessage"],
   "kwargs": {"content": "Refund me $500 for order O5002."}}
]]}

HumanMessage → user, AIMessage → assistant, SystemMessage → stripped. A ToolMessage is never an assistant turn — see What is deliberately stripped.

OpenInference indexed attributes. If you emit llm.input_messages.N.message.{role,content} / llm.output_messages.N.message.{role,content}, Neens assembles them into a message list at ingest, in index order until an index is missing:

{
  "openinference.span.kind": "LLM",
  "llm.input_messages.0.message.role": "system",
  "llm.input_messages.0.message.content": "You are a returns agent.",
  "llm.input_messages.1.message.role": "user",
  "llm.input_messages.1.message.content": "Refund me $500 for order O5002.",
  "llm.output_messages.0.message.role": "assistant",
  "llm.output_messages.0.message.content": "I can refund up to the order total."
}

This is the highest-precedence source, ahead of gen_ai.prompt / gen_ai.completion and the raw input.value / output.value blobs. Emitting these attributes on your LLM spans is the single most reliable way to get a clean transcript.

On an llm or agent span, a payload that is none of the above — plain prose, or JSON that isn’t a message list and isn’t an object — is still rendered as a turn, using the span position’s default role (input → user, output → assistant).

Chain spans: the strict rule

chain is the kind every LangChain/LangGraph node and graph maps to, so it’s where a graph-built answer lives. But a chain span’s payload is state, not dialogue. Neens admits a chain span only when the payload names its content — that is, when it does one of:

  1. parses to a structured chat message (any of the shapes above), or
  2. (on an output) yields a recognized answer field, or
  3. (on an input) yields a recognized question field.

Anything else from a chain span is control flow and is dropped. In particular, a bare string on a chain span is never a turn:

{
  "kind": "chain",
  "name": "_route_selector",
  "input": "{\"message\": \"Refund me $500 for order O5002.\", \"route\": \"returns\"}",
  "output": "returns"
}

That output is a routing token — the name of the next node. It is a perfectly good string, and under the lenient rule that llm/agent spans get, it would render as:

Assistant  returns

…as the agent’s reply to the customer, and would be captured as the expected output of any golden dataset item built from this trace. The strict rule is what keeps it out. The same rule drops true, 3, and any unrecognized state object a graph node emits.

llm and agent spans keep the lenient behaviour, because a model call’s payload is the conversation even when it isn’t well-formed JSON.

Recognized answer fields

When an output payload unwraps to no chat message — a state dict, a worker result — Neens looks for the agent’s final answer under these keys, in this priority order (not the order they appear in your object). The first non-empty match wins:

PriorityKey
1answer
2final_answer
3final_output
4final_response
5output_text
6response_text
7reply
8response
9completion
10final
11output
12result

The value may be a string or a content-block list ({"answer": [{"type":"text","text":"…"}]}), which is normalized to text.

Priority order is load-bearing: a LangGraph state that carries both the user’s echoed message and the real answer resolves to answer, because the echo keys are deliberately absent from this list. So are control keys (route, worker, passed, status) — a routing decision or guardrail verdict stays dropped instead of masquerading as a reply.

If your state schema names the final answer something not on this list, rename it or add an alias — that is the one-line fix for “my reply is missing”.

Recognized question fields

The mirror problem: a graph’s input state {"message": "Refund me $500…"} unwraps to no chat message either, so a LangGraph trace whose LLM spans carry no input messages would show a reply with no question. When Neens is reading a payload as a user turn, it looks for these keys, in priority order:

PriorityKey
1message
2query
3question
4prompt
5user_input
6user_message
7human_input
8input

Two deliberate limits:

  • Strings only. No content-block lists, so a nested state object can never be flattened into a fake user turn.
  • Inputs only. This lift never applies to an output, so an echoed input can’t masquerade as the agent’s reply.

input is last because it’s the most generic. Lifting the same question repeatedly as the graph threads state through its nodes is harmless — consecutive duplicate user turns collapse.

The two collapses

Reconstruction runs the events through two collapses, which is why a 40-span trace reads as a three-bubble conversation:

  • Consecutive duplicate user inputs collapse to one. Every LLM call in a run typically replays the same system prompt and history; without this, the question would repeat once per model call.
  • A run of consecutive assistant outputs collapses to the LAST one. One user turn drives several internal steps that each produce an assistant message — a worker writes a draft, the supervisor synthesizes the final reply, a retry re-answers. Only the last one is delivered to the user, so only the last one is shown. The drafts are still on the Spans and Raw tabs.

Both collapses are strictly consecutive: a user turn between two assistant turns breaks the run, so a genuine multi-turn conversation is preserved in full.

In a session rollup, each member trace is derived independently. The session drawer’s Conversation tab renders one labelled section per member trace, in chronological order, so the collapses never reach across a trace boundary and swallow a real turn from the previous exchange.

What is deliberately stripped

StrippedExampleWhy
system and developer role messages{"role":"system","content":"You are a returns agent."}Instructions, not dialogue. They have their own home: the System prompts in effect panel on the trace.
A message whose entire content is a JSON object{"route":"returns","confidence":0.82}, {"passed":true,"policy":"pii"}, {"order_total": 59.50}Control-plane payloads: routing decisions, guardrail verdicts, planner plans, serialized tool results.
Empty content[{"role":"assistant","content":""}]Nothing was said. A rate-limited or filtered model call contributes no bubble.

Note that a JSON array is not treated as a control payload — arrays are content-block lists, and they’re flattened into text.

Every surviving message lands in one of two buckets. assistant, ai and model roles (and a LangChain AIMessage) become assistant turns; every other role that wasn’t stripped is read as a user turn. So if your instrumentation puts tool-role messages into an LLM span’s message array, a plain-string tool result will read as a user bubble — a serialized JSON result is dropped by the rule above. Keep tool traffic on tool spans, where it shows up under Tool Calls on the Spans tab.

Where else the transcript is used

This is the highest-stakes part of the page. The transcript is not cosmetic; it is Neens’ single answer to “what did the user ask” and “what did the agent finally answer”, and several features record that answer permanently:

SurfaceWhat it takes from the derivation
Dataset item — captured OutputThe last assistant turn. This becomes the item’s captured output, and the starting point for its expected output.
Judge run comparisons and score exportsThe first user turn as the scored example’s input, the last assistant turn as its output.
Pre-prod evaluation baselines and CompareThe candidate run’s last assistant turn.
Stress tests — scenario exemplarsThe first user turn and the last assistant turn of each example trace.

Because these are the derived values and not “the last span that had output”, a trailing guardrail verdict or tool result can never be captured as the agent’s answer. The flip side is the failure mode to watch for: if the Conversation tab is empty or wrong for a trace, a golden dataset built from that trace captures an empty or wrong expected output, and nothing errors. Every judge run and every pre-prod gate then measures against it.

So before you cut a golden version, open a couple of its traces and check the Conversation tab actually shows the answer you expect.

A dataset item’s captured Input is taken from the trace’s first span input verbatim, so it may contain more than the derived first user turn (a full message array, for example). The captured Output is always the derived final answer.

Instrument for a clean transcript

In rough order of impact:

  1. Emit llm.input_messages.* / llm.output_messages.* on your LLM spans. The standard OpenInference instrumentors do this for you. It removes all guesswork.
  2. Put the final, user-facing answer under answer (or another recognized answer field) in the state your last graph node returns. This is the LangGraph fix.
  3. Name the user’s question message, query, question or prompt in your graph’s input state.
  4. Don’t return bare strings from graph nodes you want in the transcript. Return {"answer": "…"}, not "…".
  5. Keep control decisions in objects with control keys (route, status, passed) — that’s what keeps them out of the dialogue.
  6. Mark guardrail and tool spans with their real kinds so their payloads are never mistaken for turns.

Troubleshooting

SymptomCause → fix
The question is there, but no replyYour answer lives on a chain span under a key that isn’t a recognized answer field (or on a span kind that never contributes). Rename the field to answer / final_answer, or emit the final reply as an assistant message on the LLM span. Check the Raw tab to see where the text actually is.
A routing token (returns, catalog) shows as the replyA lenient span kind is emitting control flow. If it’s a chain span it’s already dropped; if it’s an agent/llm span, either re-kind it as chain, or wrap the value: {"route": "returns"} instead of "returns".
The answer appears above the questionYour enveloping span reports no end time and no duration, so its output event sorts at its start. Make sure spans are exported with an end timestamp (or a duration).
The same question repeats for every model callThe replayed history isn’t byte-identical between calls — only exactly duplicate consecutive user turns collapse. Send the current turn’s message, not a re-serialized history that varies (timestamps, ids) between calls.
Several near-identical assistant repliesA real user turn is separating them, so the assistant run isn’t consecutive. That usually means an input payload is being lifted as a user turn mid-run — check for a generic input key on an intermediate node’s state.
”No conversation could be reconstructed from this trace”Nothing in the trace named a turn: no message payloads, and no recognized answer/question fields. The Raw and Spans tabs still have everything; add one of the instrumentation changes above.
A tool result I want to show never appearsBy design — tool spans never contribute. If the tool’s text really is the user-facing reply, have the agent restate it in its final answer.
A dataset item’s captured output is emptyThe trace has no derivable assistant turn — open it and check the Conversation tab, which will be empty too. Fix the instrumentation, re-ingest, then re-add the trace; the captured output isn’t recomputed for existing items.

See also Traces & sessions, Send traces, and the FAQ.