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
| Where | Trace inspector → Conversation tab; session drawer → Conversation tab |
| Contributes turns | llm and agent spans (lenient), chain spans (strict — see below) |
| Never contributes | tool, retrieval, guardrail, custom spans |
| Turn source | A span’s input is a user turn; its output is an assistant turn |
| Ordering | By event time — input at span start, output at span end |
| Unmodified views | The 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:
| Kind | Contributes? | Why |
|---|---|---|
llm | Yes, leniently | A model call is a conversational exchange by construction. A payload that isn’t a recognizable message list is still shown as text. |
agent | Yes, leniently | An agent step’s input/output is what the agent was asked and what it produced. |
chain | Yes, but strictly | A graph node’s input and output are state. It contributes only when it names its content (see Chain spans). |
tool | Never | A tool’s arguments and result are machine plumbing, not dialogue. They’re on the Spans tab, under Tool Calls. |
retrieval | Never | Retrieved documents are context the model consumed, not something the user said or read. |
guardrail | Never | A verdict like {"passed": true} is a control decision. Rendering it made a policy check look like the agent’s reply. |
custom | Never | An 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):
| # | kind | name | input | output | start | end |
|---|---|---|---|---|---|---|
| 1 | agent | ecommerce.support.turn | – | – | 0.447 | 31.129 |
| 2 | chain | LangGraph | {"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.448 | 31.129 |
| 3 | chain | supervisor_route | state: {"message":"Refund me $500…"} | {"route":"returns","route_reason":"keyword-fallback"} | 0.450 | 29.260 |
| 4 | llm | ChatOpenAI | [{"role":"system",…},{"role":"user","content":"Refund me $500 for order O5002."}] | [{"role":"assistant","content":""}] | 0.453 | 29.257 |
| 5 | chain | _route_selector | state: {"message":"Refund me $500…","route":"returns"} | returns | 29.259 | 29.259 |
| 6 | chain | returns | state: {"message":"Refund me $500…",…} | {"worker_output":"Refund exceeds order total.","worker_status":"error"} | 29.261 | 30.227 |
| 7 | llm | ChatOpenAI | [{"role":"system",…},{"role":"user",…}] | [{"role":"assistant","content":""}] | 29.395 | 30.226 |
| 8 | chain | supervisor_synthesize | state: {"message":"Refund me $500…",…} | {"answer":"I can't process that refund: …","status":"error"} | 30.229 | 31.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):
| Time | Event | What happens |
|---|---|---|
| 0.448 | Row 2 input | State object, no chat message — but it carries message, a recognized question field → user turn |
| 0.450 | Row 3 input | Same message value lifted again → duplicate user turn, collapsed |
| 0.453 | Row 4 input | Message array; the system message is stripped, the user message is the same text → collapsed |
| 29.257 | Row 4 output | [{"role":"assistant","content":""}] — empty content → nothing |
| 29.259 | Row 5 input, then output | Zero-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.260 | Row 3 output | {"route":…,"route_reason":…} — no recognized answer field → dropped |
| 29.261 | Row 6 input | Duplicate → collapsed |
| 29.395 | Row 7 input | Duplicate → collapsed |
| 30.226 | Row 7 output | Empty content → nothing |
| 30.227 | Row 6 output | {"worker_output":…} — not a recognized answer field, and it’s only the worker’s draft → dropped |
| 30.229 | Row 8 input | Duplicate → collapsed |
| 31.127 | Row 8 output | {"answer":"I can't process…"} → assistant turn |
| 31.129 | Row 2 output | The 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:
- parses to a structured chat message (any of the shapes above), or
- (on an output) yields a recognized answer field, or
- (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:
| Priority | Key |
|---|---|
| 1 | answer |
| 2 | final_answer |
| 3 | final_output |
| 4 | final_response |
| 5 | output_text |
| 6 | response_text |
| 7 | reply |
| 8 | response |
| 9 | completion |
| 10 | final |
| 11 | output |
| 12 | result |
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:
| Priority | Key |
|---|---|
| 1 | message |
| 2 | query |
| 3 | question |
| 4 | prompt |
| 5 | user_input |
| 6 | user_message |
| 7 | human_input |
| 8 | input |
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
| Stripped | Example | Why |
|---|---|---|
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:
| Surface | What it takes from the derivation |
|---|---|
| Dataset item — captured Output | The last assistant turn. This becomes the item’s captured output, and the starting point for its expected output. |
| Judge run comparisons and score exports | The first user turn as the scored example’s input, the last assistant turn as its output. |
| Pre-prod evaluation baselines and Compare | The candidate run’s last assistant turn. |
| Stress tests — scenario exemplars | The 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:
- Emit
llm.input_messages.*/llm.output_messages.*on your LLM spans. The standard OpenInference instrumentors do this for you. It removes all guesswork. - 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. - Name the user’s question
message,query,questionorpromptin your graph’s input state. - Don’t return bare strings from graph nodes you want in the transcript. Return
{"answer": "…"}, not"…". - Keep control decisions in objects with control keys (
route,status,passed) — that’s what keeps them out of the dialogue. - Mark guardrail and tool spans with their real kinds so their payloads are never mistaken for turns.
Troubleshooting
| Symptom | Cause → fix |
|---|---|
| The question is there, but no reply | Your 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 reply | A 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 question | Your 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 call | The 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 replies | A 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 appears | By 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 empty | The 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.