asz.view
asz.view is one conversation, rebuilt from its Session Flow and its Session Data, as one document:
everything a viewer renders, and nothing a viewer must compute. It is the final form of a
conversation, the one a page draws and the one a person reads. Every talk is a tree of runs and
steps with the text the referenced records carry, and the streams, the segments, the relations,
the rounds and the files sit beside them, each verified. The evidence is inside it once, so a
viewer never opens a .sd file; the ref on a node is a citation, not a pointer to fetch.
It is never a file that the project writes. A stored copy would have to be kept in step with the
rounds and the landed files, and it would be the copy that falls out of date. Built from them each
time, it always agrees with them. Package pkg/sessionview defines and owns the shape, and three
things produce it from the same code:
| How | What you get |
|---|---|
asz conversation -json ID |
the document on standard output, indented |
asz conversation -yaml ID |
the same document rendered as YAML, with the same keys in the same order |
asz view, at /api/c/{id}/view |
the document as the page’s own response, built once per fold |
A server that holds the same .sd and .sf files, such as the SkyWalking OAP, builds the same
document and answers a conversation query with it. Every reader shares the shape, and a change to
it is a change to the version: a 1.x adds keys and never removes or renames one; a 2.0 may do
either.
The document is JSON. Keys are snake_case, as in the two source formats, and are written in the
order this page lists them. Times are unix milliseconds, rounded down, read from the .sd record a
node references; a view is read and never digested. The one exception is a record the document lists
as its own format writes it, a workspace change or a tool execution, whose time stays the RFC 3339
string the record holds. A time a
round’s header carries is null when the header has none, and 0 when its value is not a time as
Reading a record defines one. The same head
round over the same files gives the same document, so one built by asz view and one built by
another server compare equal as documents.
A complete example. asz-view-example.yaml is the whole document for
the fixture session of the format pages, three talks across a main stream and a child agent, a
tool, a synthetic error and a context reset, exactly as asz conversation -yaml prints it. It is
generated from the scenario tests/scenarios/fixture.yaml by make asz-view-example, and a test
fails when the committed file no longer matches what the code produces. Its workspace_changes
holds the one file the build changed; tests/scenarios/workspace-changes.yaml covers every
producer of them.
Top level
{"format":"asz.view","version":"1.0",
"conversation":"0438c73b-…","sessions":["0438c73b-…"],
"head":{"round":4,"digest":"2e77…"},"parser":"v1","policy":"v1+idle=10m0s",
"summary":{"title":"Check otel-rules support for meter mal","state":"verified","problems":[],
"talks":357,"steps":16121,"streams":132,"segments":50,"rounds":4,"unresolved":0,
"from":1786033245531,"to":1786502191749,
"kinds":{"llm.call":4656,"tool":5606,…},"relation_types":{"in_segment":346,…},"quality":{"exact_unique":338,…}},
"rounds":[{"round":1,"digest":"6d2d…","previous":null,"from_seq":1,"through_seq":5,"input_digest":"…",
"from_time":1786033245531,"through_time":1786322168715,"verified":true}, …],
"files":[{"file":"0438c73b-…/streams/main/transcript-…-000002.sd","format":"sd","kind":"transcript",
"seq":2,"round":null,"stream":"main","run":null,"lines":1017,"bytes":2097000,"digest":"9a39…",
"from_time":1786033290556,"through_time":1786036000000}, …,
{"file":"_conversations/0438c73b-…/rounds/r000001-6d2d93b21f79.sf","format":"sf","kind":"round",
"seq":null,"round":1,"stream":null,"run":null,"lines":3575,"bytes":1026788,"digest":"f3fe…",
"from_time":1786033245531,"through_time":1786322168715}],
"streams":[{"id":"stream/main","name":"main","role":"main","label":"","parent":"","records":10728,
"steps":7310,"talk":"talk/main/95a1…","named_by":"","opened_by":[]}, …],
"segments":[{"id":"segment/at_2_7","state":"candidate","committable":true,"talks":24,"from":1786033290556,"to":1786039329174}, …],
"talks":[{"id":"talk/main/95a1…","kind":"talk","parent":"epoch/main/0","stream":"main","at":1786033290556,
"ref":{"seq":2,"row":7},"attrs":{"loops":1,"runs":1,"trigger":"external"},
"label":"for meter mal, …","reply":"## Short answer…","runs":1,"steps":132,"tools":51,
"from":1786033290556,"to":1786033717089,"segment":"segment/at_2_7",
"children":[…],"edges":[{"type":"in_segment","other":"segment/at_2_7","dir":"out","quality":"exact_unique","via":"activity window"}]}, …],
"relations":[{"id":"rel/starts/…","type":"starts","from":"tool/toolu_…","to":"stream/a1…","quality":"exact_unique",
"via":"parent tool result","evidence":[{"seq":1,"row":12}]}, …],
"unresolved":[]}
| Key | Value |
|---|---|
format, version |
asz.view, 1.0. A reader that does not know the version stops here. |
conversation, sessions |
the conversation id, and the sessions that contributed to it, from the fold’s session nodes; one session, equal to the conversation id, for the Claude Code adapter |
head |
round and digest of the newest round the document was folded to |
parser, policy |
from the head round’s header |
summary |
title; state, one of verified, incomplete when a round or a file is missing, mismatch when a digest failed; problems, one line each, empty when verified; the counts talks, steps, streams, segments, rounds, unresolved, changes, provider_bodies, the landed provider bodies, and captured_prompts, the calls whose request is captured; from and to, when the session began and its last activity, from the session node; and kinds, relation_types and quality, the fold sized by node kind, by relation type and by how well each relation is known |
rounds |
one per round, in order: round, digest, previous (null on round 1), from_seq, through_seq, input_digest, from_time, through_time (the record time range of the files the round consumed, null when none carries a time), verified |
files |
one per .sd file, then one per round: file (its path on the wire), format (sd or sf), kind, seq or round, stream or run, lines, bytes, digest, from_time, through_time. Absent values are null. Together with rounds, this is exactly what a rebuild needs. |
streams |
one per execution stream: id, name, role (main or child), label, parent, records, steps, talk, named_by, and opened_by, every step the assembler could tie to the start of the stream as {step, stream, talk, quality}, in the order those steps happened; several means it did not choose, and neither does a view |
segments |
one per activity window: id, state, committable, talks, from, to |
talks |
one tree per talk, in time order. See the node below |
loose |
the runs and steps no talk contains, as trees from their highest such ancestor: a child’s output the fold parented to the session because the child’s stream opened no talk, for instance. Empty for most conversations. With talks, it holds every run and step of the fold within twelve levels of its root, so the document covers the whole session |
relations |
one per relation of the fold: id, type, from, to, quality, via, evidence; in the order they happened, each at the earliest record its evidence names |
unresolved |
one per reference the assembler could not resolve, open or since resolved: id, kind, ref, reason, state |
workspace_changes |
one per workspace change record the session’s files carry, joined to its step. See below. |
tool_executions |
one per tool execution record the session’s files carry, joined to its step. See below. |
Verification is content, not an error. A gap in the chain or a failed digest is written into
summary.state and summary.problems, each round says whether it verified, and the rest of the
document holds whatever could still be folded: the fold stops before a missing or broken round,
head names the last round it reached, and the rounds after the gap are listed and not verified.
A viewer shows the problem; it never gets an error instead of a document. Only a chain with no
usable round at all is an error, because there is nothing to show.
A stream’s opened_by, the relations list and a node’s edges are in the order things happened,
never in the order of an id. Inside one stream or one workflow run, a record’s position orders it:
its file’s seq, then its row, then its block. A position means nothing across streams, since a
child’s file can land before its parent’s, so streams and runs are merged by time: each next item is
the earliest of the next items of every stream, a timed item before an untimed one. Times compare as
the instants they name, as Reading a record says. An item several
records support, such as a relation, happened at the earliest of them by the same rule. Each list is
ordered on its own. An id decides only between items that one record supports, such as two relations
with the same evidence, and ids compare in code point order, as Session Flow
defines it. Talks, workspace changes and tool executions are in time order, as their own sections
say.
A node in talks
{"id":"tool/toolu_01KH…","kind":"tool","parent":"call/msg_011C…","stream":"main","at":1786033295667,
"ref":{"seq":2,"row":13,"block":0},"refs":[{"seq":2,"row":13,"block":0},{"seq":2,"row":14,"block":0}],
"attrs":{"name":"Bash","result":"available","result_join":"exact_unique","timing":"unavailable"},
"text":"{\"command\":\"ls …\"}","state":"available","bytes":145,"flags":["finished"],
"name":"Bash","result":"…","result_state":"available","result_bytes":2048,
"request_to_result_ms":1312,"request_to_result_join":"exact_unique",
"edges":[{"type":"starts","other":"stream/a1…","dir":"out","quality":"exact_unique","via":"parent tool result"}],
"children":[…]}
| Key | Value |
|---|---|
id, kind, parent, stream, attrs |
the node as the fold holds it; kind is one of the node kinds of Session Flow; attrs is as the round wrote it, numbers included, without provider_bodies, which a call gives under a key of its own |
at |
when its record happened, from the record; 0 when nothing observed it |
ref, refs |
the record it stands on and every record it covers, as {seq, row, block}, kept so a viewer can show the evidence |
text, state, bytes |
the part the node stands on: its readable text, clipped to the longest prefix of whole characters within 2,000 bytes, whether the content is available, and its full size. For a data part the text is the record’s readable text when it has one: its text parts, or the prompt inside a queued_command envelope, the form a message typed while the agent works arrives in. Otherwise it is the data exactly as the record holds it, which is compact JSON. Earlier writers put \u003c, \u003e and \u0026 there for <, > and &, so their step text can differ (see What data holds). A reader wanting the whole record reads it by address. |
usage, flags, dropped |
what else the referenced record says, copied once: on an llm.call, the token counts in, out, cache_read, cache_write from the one record usage_at names, never a sum over fragments, a count of zero left out; the record’s flags; and its dropped list, each entry its what and bytes, and why when there is one, so a viewer can say what was left out and why. A field the Session Data page does not list is not copied |
| a talk adds | label and reply, clipped the same way and described below, then runs, steps, tools, from, to, child, segment |
| a tool or agent call adds | name, failed, result, result_state, result_bytes, request_to_result_ms and request_to_result_join, the time from the request record to the result record where the assembler joined them exactly; changes and executions, the records joined to it |
a call to an MCP server has in attrs |
mcp_server and mcp_tool, the server and the tool the runtime’s name for the call addresses, where the name splits exactly (see Parts). They are the runtime’s names; which server ran the call, by its configured name, is in the call’s execution record |
a turn.duration step adds |
duration_ms, duration_measured_by |
children |
containment, in record order: by landed position, and on one record a node on the whole record before the nodes on its parts, which follow their block, then by id: a talk holds runs, a run holds steps, a call holds what it produced. A tree is written down to twelve levels below its root, a talk or an entry of loose, and a node deeper than that is left out. No talk measured has more than three |
edges |
every relation touching the node, in both directions, as {type, other, dir, quality, via}, in the order the relations happened, each at the earliest record its evidence names, so a viewer draws cross-stream flow without searching relations; a workflow launch lists the streams it started in the order its run names them |
Keys a node has no value for are absent, not null. Nothing in a document is inferred beyond what
the fold and the records say. Where the fold says unavailable, the document says it too.
Where the document trims white space from a text, or turns a run of it into one space, white space
is what Unicode’s White_Space property lists. A no-break space is white space.
A call’s result. A tool or agent call’s first reference is its request, and its other
references name what came back. They are read in order until one gives the call a result. From
each, the part read is the one its block names, or else the record’s first result part; a
record with neither gives nothing. The result is that part’s text, or its data when it has no
text. Each part read sets result_state and result_bytes, even when it has neither. failed
comes from the first part that has it, the request’s included.
A talk’s label and reply. A talk’s label is the text of the first message.external step in
it. A talk can have none. A talk opened by a command typed locally is one example, because the
runtime records no origin for such a command. The label is then the first text that is not empty
among the talk’s first three context.injection steps, in record order. A text that starts with
{"type":"deferred_tools_delta" is skipped. It is Claude Code’s record of a change in the tools
available, and it says nothing about the work. So a label is not always something a person typed.
A talk’s reply is the last message.assistant or agent.output step in it, in record order. The
earlier messages are what the agent said between tool calls, as the
Unified Conversation Model
explains. Either key is absent when no step gives it a text.
Request to result is not tool time. request_to_result_ms is the time between two records the
runtime wrote, the request and its result, tied together by the tool-use id: the difference of
their times, in whole milliseconds, with the rest dropped. It is not how long the
tool ran. It can include waiting and other work between the two records. So timing in attrs
stays unavailable beside it, for the reason the
Claude Code adapter gives. Both keys are absent when the
join is not exact_unique, when either record has no time, or when the result is earlier than the
request. A gap under one millisecond keeps request_to_result_join and leaves out
request_to_result_ms, because its value is zero.
Workspace changes
workspace_changes lists which files each tool call changed, one entry per change record, in
time order, and at one time the runtime’s record first, then by where each was read. An entry carries where the record was read from and the step it joins to, then the
record’s own fields as changes/1 lists them:
| Key | Value |
|---|---|
step |
the target: the id of the tool step this change belongs to, the one whose tool-use id the record names. Empty when no step carries the id, which is also how an unattributed change appears |
ref |
the source: the landed record the entry was built from, in the shape every node’s ref has. seq names a file under files, row the record in it counting from 1, block the part. A patch the runtime recorded sits on the Edit or Write result record, beside the raw result; a record the plugin wrote sits in a changes file, one record per line |
schema, id, captured_by, session, stream, tool, tool_name, time, basis |
the record’s identity. id is the tool-use id the record belongs to, unique per call, or gap/<root>/<step> for a change no tool window covers. captured_by is claude-code for a patch the runtime recorded on its own editing tool, asz-plugin for one the asz Claude Code plugin observed. basis is runtime_reported, tool_window, unattributed or skipped_read_only |
root, policy, window, outcome, coverage, gaps, overlaps |
where it was observed, under which rules, between which scans, what the call reported, whether the whole scope was seen, and which other windows were open on the root at the same time |
changed_files, changes |
the count, null when unknown, and one entry per file: path, operation, before and after as present, bytes, sha256, no_newline_at_end; diff, one of available, binary, too_large, unavailable; attribution and windows, which windows could have made the change; additions, deletions and hunks, each hunk old_start, old_lines, new_start, new_lines and lines prefixed with -, + or a space |
A tool step lists the ids of its records under changes, in the order workspace_changes lists
them, and summary.changes counts them. A step may have an entry captured by claude-code and
one captured by asz-plugin for the same call, with the same id; the runtime’s is listed first,
and a viewer showing one prefers it. A change
several windows could have made appears in each of their records, marked shared and naming the
others, and is counted once. A record with basis: skipped_read_only carries no changes and
means the call was not observed, never that it changed nothing.
Tool executions
tool_executions lists what an observer around a tool call saw it do, one entry per execution
record, in time order, and at one time by where each was read. An entry carries where the record was read from and the step it joins to,
then the record’s own fields as execution/1 lists them:
| Key | Value |
|---|---|
step |
the id of the tool step whose tool-use id the record names. Empty when no step of the document carries the id; the record is kept, not dropped |
ref |
the landed record the entry was built from, in the shape every node’s ref has. seq names an execution file under files |
schema, id, observed_by, boundary, session, stream, tool, tool_name, cwd, protocol |
the record’s identity: id is the observation’s own, so one call can have several entries |
server, time, duration_ms, outcome, arguments, result |
which server ran the call and where its configuration came from, when the observation ended, the time the runtime measured around the call, how it ended, and the size and digest of what was sent and what came back |
A tool step lists the ids of its records under executions, in the order tool_executions lists
them. The records are not counted in summary, and they add no node.
Provider bodies
A call step lists the provider bodies joined to it under provider_bodies: its request, then its
response, each only when the join is exact. An entry names where the body is landed. It does not
carry the body.
{"id":"call/msg_011C…","kind":"llm.call", …,
"provider_bodies":[{"role":"request","ref":{"seq":4,"row":5}},
{"role":"response","ref":{"seq":4,"row":6}}]}
| Key | Value |
|---|---|
role |
request or response |
ref |
the landed provider_body record: seq names a file under files, row the record in it |
A reader loads a body when it wants it. A body refers to pieces and bodies that landed before it in
the same session, sometimes in an earlier file, so a reader loads the session’s provider_body files
with a sequence up to the one ref names, in order, keeps them, and rebuilds the body as
Session Data describes. Every such file is listed under files.
A later body needs only the files not loaded yet. The document stays the size of the structure,
however large the bodies.
The join is not made when the document is rendered. It is made when a round is parsed, and the round carries it, so every reader of a conversation — asz’s own page, and a server that mirrors the format — shows the same bodies on the same calls without opening one. See Session Flow. A conversation whose rounds were parsed before that carries no bodies on its calls; parsing its chain again from the landed files brings them in.
A response joins to the call whose message id it carries, and so does a request that names its
call, when exactly one request names it. A request that names no call, only its prompt and the
request id of the call before it in its chain, joins to the call of a stream whose previous call’s
response carries that request id and whose prompt is the one the request names. The first call of
a stream matches a request that names no previous request. Such a request joins only when exactly
one request and exactly one call carry those two ids. A synthetic call, which the runtime wrote
without calling the provider, takes part in no join. No request of that kind joins in a stream
whose landed transcript lines have a gap, since a call may be missing between two that look
consecutive; responses, and requests that name their call, still join. A body that joins to no call, such as
the request that names the session, a compaction request, or a retried request, is listed on no
step; its file is still under files. Nothing is joined by position or by time.
summary.provider_bodies counts the session’s landed bodies as of the folded chain, joined or not, and
summary.captured_prompts the calls that list a request.
Rendering the whole conversation
The document is complete: a viewer draws every view of a conversation from it and fetches
nothing else, however many rounds the session was parsed in. A session landed and parsed in three
stages has three rounds and its landed files cut at each stage, and the document built from the
head holds every round, every file, and every talk, run and step, exactly as one built from a
single parse would; the scenario three-rounds checks that at each stage, and every scenario
checks the property view_covers_the_session at its end. This is how each view reads it.
| View | Read |
|---|---|
| Transcript | talks, in order. Each talk’s label is usually the person’s input and its reply the last thing the agent said in it, as A node in talks defines them; its children are the runs, a run’s children the steps, and a call’s children what it produced: thinking, messages, tools. text is what to show for a step, name and result for a tool, usage on a call. loose holds whatever no talk contains, and is usually empty. |
| Flow timeline | every node of every tree by at, with kind and stream; a node with at of 0 was never observed at a time and is placed by its position. |
| Cross-stream flow | edges on a node, and relations as the whole list: starts from an agent call to the child’s stream, reports from the notification that resumed the parent, ends_with from a stream to the child’s output, result_of from a started stream’s final step back to the call it returned to, follows between epochs across a reset, summarizes from a summary to its boundary, in_segment from a talk to its window. Containment never crosses a stream; a child’s work is under the child. |
| Streams and segments | streams with role, label, parent and opened_by, the step that started each; segments with the span of the talks placed in them. |
| Evidence | a node’s ref and refs, {seq, row, block}, name the record and the part it stands on. The text is already on the node, clipped to 2,000 bytes with the full size in bytes; only a reader that wants the whole of a longer part goes to the record, by that address, in files. |
| Verification | summary.state and summary.problems, every round’s verified, and every file’s digest, lines and bytes. A gap or a failed digest is content here, never an error in place of the document. |
| Counts and time | summary: the counts a list shows, the session’s from and to, and the fold sized by kinds, relation_types and quality. |
Nothing in a document is inferred beyond what the fold and the records say. Where the fold says
unavailable, the document says it too, and a viewer shows that word rather than a guess.
The YAML rendering
-yaml is a rendering of the JSON, not a second format. It is produced from the JSON, so the keys
are the same and in the same order; mappings are blocks; scalars are plain, and quoted only where
YAML would otherwise misread them, so version is "1.0" and a title with a colon is quoted; an
empty map is {}; a text with line breaks is a block scalar. Reading the YAML back gives the same
values as the JSON.
Reading it
asz view serves the document at /api/c/{id}/view and builds it once per fold, so a second reader
pays nothing until a new round arrives. asz conversation -json ID prints the same document to
standard output, and -yaml prints it as YAML, for a terminal or a diff. The page draws the same
document with Horizon’s conversation renderer, which asz embeds from a pinned Horizon commit, so a
conversation looks the same in asz view and in the SkyWalking UI.
The largest conversation measured, 357 talks and 16,121 steps, is 19 MB as one document and was
built in 0.7 s.
A running asz view notices new rounds without being told. On every read it lists the
conversation’s rounds directory and compares the newest round there with the round it folded. When
they differ, it folds again and builds a new document. So an asz view beside a separate
asz collect stays current. The comparison is with the round the fold reached, not the newest one
listed, so a round that was still being written at one read is picked up by the next.
The page writes nothing derived to disk, such as a read index. It folds the chain when asked, because folding was measured to be fast enough. The sample was the largest session of a corpus of 62 Claude Code sessions, 1,100.1 MB of landed records. It holds 53,106 nodes and 922 talks.
| On that session | Took |
|---|---|
| fold the whole chain | 302 ms |
| build the ordered talk list | 1 ms |
| one talk’s subtree, at the 99th percentile | 13 microseconds |
| walk every talk’s subtree | 11 ms |
A read index is added only when reading one talk takes more than 200 ms at the 99th percentile.