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, read from the .sd record a node references; a view is read and never digested, so it carries no RFC 3339 strings. 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; 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}; 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, so the document covers the whole session
relations one per relation of the fold: id, type, from, to, quality, via, evidence
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.

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 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
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 data as 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; the record’s flags; and its dropped list, so a viewer can say what was left out and why
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
a turn.duration step adds duration_ms, duration_measured_by
children containment, in record order: a talk holds runs, a run holds steps, a call holds what it produced
edges every relation touching the node, in both directions, as {type, other, dir, quality, via}, ordered by relation id and then direction, so a viewer draws cross-stream flow without searching relations and the same fold gives the same list

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.

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. 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. 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.

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, 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.