Session Data
Session Data is what was actually in a conversation, in one shape regardless of which agent
produced it. A landed file has the extension .sd. Its first line is a header carrying everything
constant for the file, and its last line closes the file with a digest. Every line between them is
one source record, converted, with its content broken into parts named for what they are: a
message, a thought, a call, its result.
The conversion happens once, while the source is read, and never again. A runtime’s vocabulary
stops at its adapter. A reader is handed parts, so there is no runtime shape for it to reach into.
A dialect that meets something it cannot describe keeps the bytes verbatim in an unknown part
rather than guessing or dropping them.
The format is public. Package pkg/sessiondata defines it, and a third-party adapter produces it.
The schema is sd/1.
One record per line
Every record is one line, for two reasons.
A row is a line. A round addresses a record by the file’s sequence and the record’s line in it, two integers rather than a path into nested structure. They stay valid because a landed file is never rewritten. See Addressing a record.
A reader can decode one record at a time and never hold the whole file. A source record is landed
whole however large it is (see Append cursors), so a line has no
fixed length. A line reader with a fixed limit, such as Go’s bufio.Scanner at its 64 KB default,
stops at the first longer line.
Header
{"h":1,"schema":"sd/1","seq":2,"at":"2026-09-01T12:56:03.1818Z","kind":"transcript",
"adapter":"claude-code-local/0.1.0","dialect":"claude-code/1",
"src":"-Users-me-github-skywalking/0438c73b-….jsonl","session":"0438c73b-…","stream":"main"}
| Field | Meaning |
|---|---|
h |
envelope version, 1 |
schema |
sd/1 |
seq |
the landed sequence number, monotonic per session |
at |
when the file was collected |
kind |
what it was collected from: transcript, agent_meta, journal, workflow_manifest, workflow_script, changes, execution or provider_body. otlp_log and otlp_span are reserved for a push transport, and no adapter writes either. |
adapter |
how the records were acquired, with its contract version |
dialect |
whose schema they were read as. A push receiver and a local reader for one runtime share a dialect and nothing else. |
src |
the source, relative to the adapter’s source root, with forward slashes on every platform |
session, stream, batch |
the session, the execution stream (main or an agent id), and the group of children a workflow run started. A provider_body file names neither a stream nor a batch |
A reader refuses a header unless h is 1, schema is sd/1, and kind, session, src and
dialect are all present. The writer in pkg/sessiondata fills in h and schema, and refuses
to start a file whose header fails the same check. dialect is required because without it nothing
says whose vocabulary the parts were read in. A later reader could not tell a shape it should
understand from one it should not.
Because the header holds what is constant, no record repeats it, and a file can be read on its own, away from the directory it was landed in. It names its session, its stream or batch, its kind and its sequence.
src is kept because the storage root does not follow the source’s layout. The root files a
session under its id and puts every child stream flat under streams/, keyed by agent id (see
Storage Root). For Claude Code, that drops two things the source path holds: the
project directory a source file sits in, and the workflow directory a child’s transcript sits in.
src is the only place both survive. It uses forward slashes on every platform, so a root reads
the same on the machine that collected it and on any other. With a record’s off and sha, it
lets a reader check the record against its source for as long as the source exists.
File kinds
One .sd file holds one cut of one source. The header’s kind says which, and the records of
each kind carry a characteristic set of fields. A provider_body file is the exception: its source
is a directory with one file per body, and each record names its own file.
kind |
Collected from | Its records | What refers to it |
|---|---|---|---|
transcript |
one execution stream: the main stream or a child agent’s own | the conversation itself: inputs, calls in fragments, results, injections, resets. stream on the header is main or the agent id. |
almost every node of a round |
agent_meta |
what the runtime recorded about an agent: about a child agent when it started it, or, from Kiro, about the session | one record, from: runtime, with child and label, and a data part. Kiro’s is the session’s header, in the main stream, with label and no child |
the child’s stream node, for its label; in the main stream, the session node’s title |
journal |
a workflow run’s journal | one record per event, from: runtime, with child and batch; a child_result flag on the record that returns a child’s value |
agent.output nodes and ends_with relations |
workflow_manifest |
a workflow run’s manifest | one record with batch and label, and a data part |
the run’s name |
workflow_script |
the program a workflow ran | one record whose part is unknown: the source is a program, not data |
nothing; kept because it is part of the session |
changes |
the files the asz Claude Code plugin saw a tool call change, one line per call | one record per observed call, with id, tool and time lifted from it and the line whole as one data part, a changes/1 record; no from and no flags, so assembly emits no node for it |
nothing in a round; the view joins each to its step by tool. A transcript’s Edit and Write results carry the same record as a second data part, from the runtime’s own patch. See the Claude Code plugin. |
execution |
what the asz Claude Code plugin’s hook saw of each call to an MCP server, one line per call, in a file of its own beside the stream’s change file | one record per observation, with id, tool and time lifted from it and the line whole as one data part, an execution/1 record; no from and no flags, so assembly emits no node for it |
nothing in a round; the view joins each to its step by tool, and one step can have several. See Execution records |
provider_body |
what a model call was sent and what came back: the bodies Claude Code wrote for its provider, one file per body, or a LangChain run’s inputs and outputs |
one record per body, with id unique in the session (for Claude Code, the body’s file name without .json; for LangChain, the run id followed by :request or :response), run on a request, call on a response, model, and the parts described in Provider bodies; no from and no flags, so assembly emits no node for it. The header’s src is . and it names no stream |
nothing in a round; the view joins each to its call. See Claude Code Provider Bodies and LangChain |
A source that is one document, not a stream of lines, lands as one record with ord 1 and off 0.
Its sha and bytes cover the whole document. The Claude Code adapter’s agent_meta,
workflow_manifest and workflow_script files are of this kind, as is the Kiro adapter’s
agent_meta, and a new version of the document lands as a new file (see Snapshot cursors).
Files of every kind are bound by the rounds’ input_digest, so a session travels or archives as
all of them. A session’s agent_meta, workflow_manifest and workflow_script records carry
no time, so those files have no record time range.
Record
{"ord":12,"off":25086,"sha":"4fed1b624096","bytes":1524,
"id":"c8b85d73-…","parent":"a5f0a42d-…","call":"msg_011Cdm…",
"from":"agent","time":"2026-08-06T16:57:22.652Z","flags":["finished"],
"usage":{"in":2,"out":331,"cache_read":20729,"cache_write":19709},
"parts":[{"k":"call","id":"toolu_015jKz…","name":"Bash","data":{"command":"make check"},
"state":"available","bytes":181}]}
Two halves. The record level carries provenance and the identifiers structure is built from, in role names. The part level carries what the content is.
| Field | Meaning |
|---|---|
ord, off, sha, bytes |
where the record was in the source: its line number counting from 1, the byte offset its line starts at, the first twelve hexadecimal characters of the SHA-256 of its bytes, and their size. The source record is not always kept whole. What data holds describes what its parts keep. The digest stays, so provenance is provable and a record that claims a source it did not come from is detectable. |
id, parent |
the record’s own identity and its containment parent |
call |
the provider call this record is a fragment of |
run |
the agent loop it belongs to, one per trigger |
continues |
on a reset boundary only, the record the new context resumes from |
tool, child, batch, started_by |
joins the runtime states outside the content: the call a record refers to (a notification names the call it completes), the child stream a record names, the group it belongs to, the stream that started this one |
label |
a name the runtime gave something. The only naming evidence in the data. |
from |
who produced it: agent, external, runtime. A record’s type is not what a record is; most records that look like a person are a tool answering. |
time, trigger, flags |
when, what started the loop, and what the record is beyond its producer. See Flags |
usage |
what the provider reported: input, output, cache read and cache write tokens. Meaningful only where the call finished. |
model |
the provider model the call ran on, as the runtime named it, on the records of a call. What a token count is reported under. Empty on records landed before it was kept. |
parts |
the content |
dropped |
what the conversion chose to leave out, with its size and the reason |
Empty identifiers are common and mean the runtime supplied none. Nothing is inferred to fill them.
asz verify checks ord, off and bytes without the source. A provider body is a whole
document at ord 1 and off 0, so for it asz verify rebuilds the body instead, as
Provider bodies says. In each stream or run, across its
files of one kind, ord must run 1, 2, 3 with no gap, starting at 1. The first record must start
at byte 0, and each record after it at the byte after the previous record’s line ends. Where the
stream has an append cursor, the records must reach the line and the byte the cursor names (see
Append cursors). A skipped line, or a lost file of a source that
is a stream of lines, would otherwise show only as a shorter conversation. The Claude Code
adapter’s agent_meta, workflow_manifest and workflow_script files are each one document with
a snapshot cursor, so a lost one is found only by the round chain (see
Retention). A repeated record is not a gap (see
Landed files).
Flags
flags names what a record is beyond who produced it. An adapter sets each one by a rule over its
source’s fields, never by guessing from text or size, so a reader can act on a flag by rule.
| Flag | On a record that |
|---|---|
external_input |
carries input from outside the agent, including input that exists only as an attachment |
injected |
carries material the runtime put into model context |
system_prompt |
carries the system prompt the runtime sent, or may carry it |
tool_schemas |
carries the schemas of the tools the runtime advertised, or may carry them |
finished |
carries a provider stop reason, so the call finished and its usage counts are real |
synthetic |
is an assistant-role record the client wrote. No model produced it |
context_reset |
is an explicit model-context reset |
reset_summary |
is the summary that reset carried forward |
child_result |
returns a child’s value. See below |
error |
is a provider or runtime error |
launch_ack |
acknowledges that a child started, which is not its result |
turn_duration |
carries the runtime’s own measurement of a turn |
command |
is a command run in line rather than said to the model |
notice |
is the runtime telling the requester about the session itself |
auxiliary |
names a child stream that is a plain model call the agent made inside its own work, not a child agent |
system_prompt and tool_schemas exist so a server that shows a conversation to the people an
agent served can withhold what the runtime sent the model, by name rather than by guessing from the
text or the size. A record that may carry them is named for them too, such as a line whose
fields cannot be read, since a reader withholding them is better kept from it than shown it. The
Claude Code mapping says which attachments carry
them, and LangChain which records can.
child_result is not only on a journal record. On a transcript record it marks a result that
is a child’s synchronous return, which is the parent’s copy of the child’s output. Assembly reads
the flag only on a journal record. Nothing removes the parent’s copy, so it stays as the result
of the call that started the child.
Reading a record
The format is JSON, and a reader in any language reads it with its own JSON library. The rules below and the rest of this page are the contract. A reader does not have to copy what asz’s code does with input that asz never writes.
Every field of a record, as the table in Record lists them, has one type. ord, off
and bytes are integers, and ord and off are never negative. flags is a list of strings.
parts and dropped are lists of objects, typed in Parts and Dropped.
usage is an object whose in, out, cache_read and cache_write are integers. Every other
field is a string. An integer is a whole number, written with no fraction and no exponent, that
fits in 64 bits with its sign. A field written as null is the same as a field left out. A reader
ignores a field this page does not list, so a later version can add one.
A line with a field of another type is not a record. asz’s reader stops at a line it cannot
decode, such as one whose off is a string, as it stops at a line that is not JSON, and reads no
record after it in the file.
A reader may count a line that holds more than 256 objects and lists open at once as one that does not decode, and the OAP does. No landed line comes near that. Measured over 423,944 lines of 94 real conversations, the deepest held 16, a model request carrying a tool’s JSON Schema in its data.
A time is written as RFC 3339: a four-digit year, the letter T, the time of day to the
second with a fraction of up to nine digits or none, and Z or an offset such as +08:00. Times
compare as the instants they name, never as text. As text, 2026-01-01T12:00:00.11Z sorts before
2026-01-01T12:00:00.1Z, and 2026-01-01T12:00:00.5Z before 2026-01-01T12:00:00Z: in both
pairs the first is the later time. 2026-01-01T20:00:00+08:00 sorts after 2026-01-01T12:00:01Z
as text, though it is a second earlier. A value that is not such a time means the record has no
time.
Addressing a record
A round points at a record with {seq, row}, and at one of its parts with {seq, row, block}
(see Session Flow). In Session Data terms:
| Field | Is |
|---|---|
seq |
the seq in the file’s header |
row |
the record’s line after the header, counting from 1 |
block |
the part’s position in parts, counting from 0 |
The index numbers every part, including parts it has no use for, so a number names the same part
to the index, to a round and to a reader. A dialect writes one part for each piece of content in
the source record, in source order. Content it cannot describe goes in as an unknown part in the
same place, never left out. A part the adapter adds beside the content, such as the changes/1
record on an edit result, comes after them.
Provider bodies
A request Claude Code sends repeats most of the request before it: the whole message list of its
chain, the system prompt and the tool schemas. A provider_body record keeps only what its session
does not hold yet, and says how to put the rest back.
Its parts come in source order. Each is a data part holding a piece of the body, byte for byte: a
tool definition, or a JSON string of 1 KiB or more, with its quotes. The last part is a data
part that asz builds, the manifest, schema provider_body/1:
{"schema":"provider_body/1","role":"request","src":"fcc261af-….request.json",
"sha256":"<SHA-256 of the whole body>","bytes":137054,"depth":1,"chain":"3e1f…",
"model":"claude-opus-5","session":"9a91…","run":"75db…","previous_request":"req_011C…",
"segments":[{"copy":{"from":"03455f62-….request","sha256":"…","len":26777}},
{"lit":"…"},{"part":0},{"piece":"<SHA-256 of a piece>"},{"lit":"…"}]}
| Key | Value |
|---|---|
role, src, sha256, bytes |
request or response, where the body came from relative to the adapter’s source root, as a header’s src is, and the digest and size of the whole body |
segments |
joined in order, they are the body. lit is a JSON string whose UTF-8 bytes are literal bytes of the body. part is a part of this record. piece is the SHA-256 of a piece an earlier record of the session holds. copy is the first len bytes of the earlier body whose record id is from and whose digest is sha256 |
depth |
how many copies lie between this body and one with none. It is at most 32; a body whose base is that deep copies nothing |
bytes limit |
a body may claim at most 256 MiB, and a rebuild stops as soon as it passes the size its record claims, so a damaged record cannot make a reader hold more than one body |
chain |
the first sixteen hexadecimal characters of the SHA-256 of the request’s first message, with the cache marker taken out. It chooses which earlier body a copy comes from. It is storage, and a join must not rely on it |
model, session, call, request, previous_request |
the values the body carries that join it, as its adapter read them, in the model’s words: the session it names; on a response, the call it answers and the provider’s id for its request; on a request, the call it was sent to when its adapter knew it, and otherwise the provider request id of the call before it in its chain. Empty when the body carries none. The adapter’s glossary says what the runtime calls each |
A reference is a digest or a record id, never a file and a row, because asz repack renumbers files
and rows and carries record lines unchanged. Every reference points at a record that landed before
this one in the same session, so a session rebuilds on its own. A reference may point into an earlier
file: the tool schemas, for instance, land once, in the session’s first provider file. So to rebuild
a body in the provider_body file with sequence N, a reader reads the session’s provider_body files
with sequence N or lower, in order, and keeps them: a later body needs only the files it has not read
yet. Package pkg/providerbody does this.
Keeping each file on its own would repeat the tool schemas, the system prompt and one whole request in every file. On the second capture, cut into three files of ten bodies, that took 43.9% of the bytes Claude Code wrote instead of 21.7%, and a long session with requests of several megabytes would keep almost nothing.
A body can have nothing to refer to: the first body of a session, the first of a chain, one past the
depth limit, or one in a session whose earlier provider_body files are gone from the root. It lands
with all of its pieces, and rebuilds from its own record. A body that refers to a file the reader does
not have does not rebuild. asz verify reports it, and a receiver that keeps a session’s files in
several storage stages has to read the earlier files from whichever stage holds them.
The adapter checks each record before it lands: it writes the record with the Session Data writer,
reads it back and rebuilds the body. A body that does not come back whole, or is not one JSON object
in valid UTF-8, lands whole in one unknown part, whose text says why it was not cut. asz verify rebuilds every landed
body and compares it with sha256, and reports a reference to something the session does not hold.
A record whose new pieces are larger than the collector’s max_delta_bytes has its largest pieces
taken out until it fits, as Too large to land describes for any record, and
never its manifest. This is the first request after a person pasted a screenshot. A piece taken out
keeps its place, so every segment still names it, and its sha256 is the piece’s digest, so a later
body that holds the same piece names it as any earlier piece. A body made of short strings keeps
nearly all of itself in the manifest’s literals, so when the record is still too large with every
piece taken out, the body lands whole in one unknown part, and that part is taken out.
A body with a piece taken out cannot be rebuilt from the root: Session.Body returns ErrTooLarge,
and Session.BodyFrom rebuilds it from another copy of the body, such as the file the runtime wrote.
A reader registers a piece taken out by its sha256, as any piece, so a later body that names it is
read, and one that names only other pieces still rebuilds. The adapter’s check before landing
rebuilds the record with the pieces it took out. No body copies the front of a body that cannot be
rebuilt. asz verify counts these bodies apart from damage, and still holds the record’s bytes
and sha to its manifest.
On two captures of Claude Code 2.1.260, the landed provider_body files were 25.5% and 21.6% of the
bytes Claude Code wrote, and all 42 bodies rebuilt exactly.
Closing line
{"t":"end","records":118,"digest":"c41e…"}
Every file ends with this line. records is the number of record lines, and digest is the
SHA-256, in hexadecimal, of every byte before this line: the header and every record, each with its
newline. The writer computes it as it writes, so a file can be checked on its own.
A reader refuses a file with no closing line, or one whose digest or count does not match. A file
cut short would otherwise read as a shorter conversation, with nothing saying so. asz verify
reads every landed file this way.
Parts
| Kind | Is | Carries |
|---|---|---|
text |
readable text | text |
reasoning |
the model’s own reasoning | text when the runtime kept it |
call |
a request to run something | id, name, data, and server and server_tool for a call to an MCP server |
result |
what a call returned | of, failed, and text, data or both |
media |
an image or a document | media, data |
data |
structure that is not prose: a record the runtime keeps for itself, a manifest | data |
unknown |
content the dialect could not describe | the bytes in data as one JSON string, the bytes themselves or their base64 when encoding is base64, and the reason in text |
In a part, k, text, id, name, of, server, server_tool, media, encoding, state
and sha256 are strings, bytes is an integer, failed is a boolean, and data is any JSON value.
server and server_tool say which MCP server a call is addressed to and which of its tools. An
adapter sets them only where its runtime’s name for the call splits exactly. Claude Code names such a
call mcp__<server>__<tool>, and the split is made only when exactly one __ follows mcp__,
counting the ones that overlap: a name that holds a second __, or underscores that run together
as in foo___bar, could split two ways, so it is left whole. The server is Claude Code’s
form of the configured name, so “claude.ai Claude Docs” is claude_ai_Claude_Docs. The name the
server was configured with is in the call’s execution record, when there is one.
Every part carries state and bytes. state is one of available, truncated, redacted,
omitted, unavailable or too_large, or removed in a skeleton, and bytes is
the size of the original even when the part holds less. A reader is always told how much of the
original it has.
Too large to land
A landed file travels whole, so the collector’s max_delta_bytes is the most a receiver has to store
at once. A collector cuts a file by what its records take once written, not by the bytes they were
read from: a record carries an envelope its source line does not, and 4 KiB of short source lines
measured 19 KB of records. One record cannot be cut: a pasted screenshot measured 4.6 MB in one
transcript line. Such a record lands alone in a file of its own, and its largest payloads are taken
out, one at a time, until the file fits.
A part taken out keeps its place, its kind and every identifier: id, name, of, server,
server_tool, failed and media. Its data and encoding go first, and then its text, except
an unknown part’s, which is the reason its bytes were not described rather than content. A part
with text and data, such as a result with the runtime’s structured copy of it, so keeps its readable
text while it can: its state is truncated. A part with nothing left says too_large. Its
bytes is the size of the original, and its sha256 is the SHA-256 of what was taken out first:
an unknown part’s bytes, other data compacted as the writer lands it, or the text of a part with
no data. A reader still sees the step, and can match the payload against another copy of it.
A changes/1 record and an execution/1 record are evidence a step joins to by its tool call, so
neither is ever taken out whole: a reader that found nothing would read that the call changed no file
or ran nowhere. A change record is made smaller in its own format instead. Its file with the largest
hunks loses them and says its diff is too_large, as a diff too large to compute says, and its
additions and deletions stay. This goes on, file by file, until the record fits. When no file has
hunks left, its files, overlaps and gaps go, and it keeps its identity, its tool, its window and
changed_files. Its part says truncated, with the original’s bytes and sha256, and the record
still joins its step. An execution record keeps sizes and digests only, never what was sent or
returned, so it is a few hundred bytes and is left as it is.
Of two payloads of the same size, the one in the earlier part goes first, so the same record always
loses the same parts. No other part carries sha256. A record that is still too large with every
payload taken out lands as it is: what is left is its identifiers, its label and the list of its
parts. asz repack carries records as they landed, and takes nothing out.
What data holds
data is one JSON value. What it holds depends on the part:
- A piece of the source’s own JSON: a call’s input, a result’s structured form or content that is
not text, an attachment with no prose, or a record or document with no message. A journal line,
sidecar, manifest, system record or line the asz plugin wrote can land whole this way. The
writer inserts that JSON after
json.Compact, keeping its bytes apart from whitespace between tokens. This keeps each landed record on one line. Whitespace inside strings is unchanged. - Bytes the dialect could not describe: an
unknownpart holds them inside one JSON string, using base64 when needed, as described below.Part.Rawreturns the original bytes whatever escapes that string uses. - A value the adapter built: a
mediapart’s base64 is written again as a string, and achanges/1record derived beside a result uses asz’s own encoding. These are not source slices.
The writer never re-encodes data, so its bytes after compaction do not depend on whether asz
uses Go’s JSON v2 engine. Existing escapes stay as the source wrote them. Unknown strings and
derived change records still use Go’s json.Marshal, and plugin lines keep the escapes their
producer wrote. Strings the writer encodes itself, such as text, label and identifiers, are
values rather than source bytes. It writes <, > and & literally in those strings, but escapes
U+2028 and U+2029 as \u2028 and \u2029.
Whitespace between tokens is removed without a dropped entry or warning. If a runtime starts
writing such whitespace, those bytes will be lost silently. The
scenario check compares source bytes only in scenarios; it does not
monitor real source files.
Earlier writers, including asz 0.2.0 and development builds before this change, encoded <, >
and & as \u003c, \u003e and \u0026, including inside data. They encoded U+2028 and U+2029
there as \u2028 and \u2029. The JSON values are the same, but the bytes can differ. Old files
are never rewritten, and asz repack carries their record lines unchanged. Old and new files can
share one root and round chain; each digest covers the bytes actually written.
Measured on 2026-09-12 on one machine’s Claude Code sources, the writer kept all 229,341 parts
carrying source JSON byte for byte. The earlier encoding kept 144,464 (62.99%). The sample held
6,377 files from 85 sessions, with 444,881 source records, copied over 34 seconds and then read
from the frozen copies. No source JSON in that sample needed whitespace removed. Both Go 1.27.1
JSON engines gave the same result through the final writer and reader. Separately, all 177
unknown parts returned their bytes through Part.Raw. The 2,560 derived change parts were not
counted as source JSON, and the sample held no plugin change records.
No fixed rule could decide how much of a part to keep or show, because tool output varies too much.
The measured corpus is 62 Claude Code sessions: 3,032 files and 1,100.1 MB of source records. One
tool result in it was 1.1 MB. Over its 28 sessions larger than 1 MB, the share of a session that is
tool output ran from 17.3% to 83.5%, with a median of 35.1%. A rule tuned to the median is wrong by
a factor of two at both ends. So every part states its own size and state, and a reader decides.
No built-in adapter writes omitted, and a part one writes as truncated is one made smaller to fit
the budget, so no part they write is clipped otherwise. Clipping is
left to a reader, such as the asz.view document, which clips a
node’s text and gives the full size beside it. A part is still not always all the runtime gave.
The Claude Code dialect leaves some content out with no dropped entry. Its
Session Data mapping lists these as known gaps.
An unknown part’s text is not content. It is the dialect’s reason for not describing the bytes,
such as the record is not valid JSON or block type "x" is not one this dialect describes. Its
state is available, because every byte is kept, and bytes is their size. A reader that shows
text as the message would show the reason in its place.
An unknown part’s data is always one JSON string, and it holds the bytes in one of two forms.
When the bytes are valid UTF-8, the string is the bytes themselves, and the part has no encoding.
A JSON string cannot hold bytes that are not valid UTF-8: Go’s JSON encoder writes U+FFFD in their
place, and the byte is lost. So when any byte is not valid UTF-8, the string is all of the bytes in
standard base64, and encoding is base64. encoding has no other value, and no other kind of
part carries it. A reader that does not know encoding still reads one string, and shows base64
text where the bytes were not text. Part.Raw in pkg/sessiondata applies the rule.
Every unknown part landed before encoding existed is one string with no encoding, and reads as
it always did. Where its bytes were not valid UTF-8, that string holds U+FFFD in their place.
Claude Code and the asz plugin write UTF-8, but such bytes can still reach an adapter. A write cut
short and joined to the next line is one way, and a workflow script saved in another encoding is
another. Measured on 2026-09-11 on one machine’s storage root of 60 sessions, 151 of its 408,439
parts were unknown: 150 workflow scripts and one block of a type the dialect did not describe.
None of them held U+FFFD. So none had lost a byte, and all of them keep the form without
encoding.
Media is kept inline. A media part’s data is the base64 text the runtime wrote, as one JSON
string. media is its media type, and bytes is the length of the base64 text. There is no
separate store for media. The measured corpus held 41 image blocks, so inline costs little. A
runtime that sends images routinely should be measured again.
A result can carry text and data together: the readable output, and a structured form of the
same result that the runtime gave beside it. A reader that reads only one of them loses the other.
failed is absent, not false, when the runtime said nothing. Most results carry no error flag,
and reading that as success would report something the runtime never claimed.
The set of kinds is small because the thing described is small. Measured across a corpus of 3,032 Claude Code files, six content-block shapes exist and four cover 99.99% of content blocks. The Claude Code Session Data mapping gives the counts.
Execution records
An execution/1 record says what one observer around a tool call saw it do. The transcript says what
the model asked for and what came back. It does not say which server ran the call, how long the call
took, or what the server was sent. The one observer today is the asz Claude Code plugin, whose hook
runs after every call to an MCP server. Measured on Claude Code 2.1.282:
- The hook after a call names the server and where its configuration came from, the time the runtime measured around the call, and the input as the server received it. A hook before the call that rewrote the input changed what the server received and what this hook sees, while the transcript kept the model’s own input.
- An error the server returned, a lost connection and a timeout all reach the failure hook, not the success hook.
- A call the runtime refused never reaches a hook, so it has no record.
- A server’s declaration that a tool only reads, or can destroy, reaches neither the model, nor the hooks, nor the transcript.
| Field | Holds |
|---|---|
schema |
execution/1 |
id |
the observation’s own identity: the tool-use id and where it was observed. One call can have several records, each with its own id. The same event handed over twice is one record |
observed_by, boundary |
who observed the call, asz-plugin, and where it stood, client_hook: the runtime’s hook after the call |
session, stream |
the session, and main or the agent id the call ran under |
tool, tool_name |
the call’s tool-use id, the only join to its step, and the name the runtime called it by |
cwd |
the working directory the runtime reported, which the collector’s session filters are judged by |
protocol |
mcp |
server |
name, the server’s configured name, and source, where its configuration came from in the runtime’s own word: for Claude Code user, project, local, or dynamic for a server given on the command line |
time |
when the observation ended |
duration_ms |
the time the runtime measured around the call, which includes any waiting before it started. It is not the server’s own time. Absent when the observer did not say |
outcome |
returned, failed or interrupted |
arguments, result |
only the bytes and the sha256 of the value as compact JSON with sorted keys, and state size_only. Two observations of one value have one digest, so a digest that differs from the model’s input shows the input was changed on the way. result is only on a call that returned |
Dropped
"dropped":[{"what":"reasoning signature","bytes":588,"why":"a provider verifies it; a reader cannot read it"}]
dropped exists so a loss is stated rather than silent. It lists only what the dialect understood
and chose not to carry. Everything it did not understand travels as an unknown part, so a later
version of the dialect can interpret it without collecting again. In an entry, what and why
are strings and bytes is an integer.
A source record is not always kept whole, so a .sd file is the only landed copy of what its parts
hold, and everything above the adapter treats it as the authority. Content that was converted to
text, such as a message or a thought, needs the runtime’s source to correct a conversion error.
Claude Code deletes its own transcripts, and a storage root outlives them (see
Storage Root). An unknown part can be interpreted again from its original
bytes in the store. A part that keeps the source’s JSON can also be interpreted again, subject to
the whitespace and older-file limits in What data holds.
Skeletons
When storage.retention removes a landed file, a skeleton takes its place: the same name with the
extension .sk, in the same directory, written once and read-only. A skeleton is Session Data
compressed with gzip, and it is local: it is never sent. It keeps what a conversation’s structure is
built from and none of the content, so the index rebuilt from it is the index rebuilt from the
landed file. Retention says when a file is removed.
-
The header, whole, with one more key,
removed:{"digest":"9f2c…","size":204811,"records":118,"at":"2026-10-13T10:00:00Z","generation":1, "receiver":"accepted","owner":"grpc:%2F%2Foap:11800||me@laptop"}digest,sizeandrecordsdescribe the landed file: the SHA-256 of its whole bytes, which a round’s input digest binds, its size in bytes and its number of records.atis when it was removed, andgenerationwhich removal of the session removed it, counting from 1.receiversays what the receiver confirmed of it:accepted,rejectedwhen it took the file and rejected some of its records, orunconfirmedwhen no answer confirmed it.owneris the owner its session is sent under, aspush.staterecords it. -
Every record, in order, with
ord,off,bytes,id,parent,call,run,continues,tool,child,batch,label,started_by,from,time,trigger,flags,usage,modelanddropped. A provider body keepsshatoo; any other record drops it. -
Every part, in its place, because a part’s position is its block number. Each keeps
k,bytes,failedandsha256, and itsstateisremoved, ortoo_largefor a part that was too large to land, which held nothing to remove. Acallkeepsid,name,serverandserver_tool, and aresultkeepsof.text,data,mediaandencodinggo, with three exceptions, each made only when the data decoded:- A
changes/1record, in any data part, keepsschema,id,captured_by,tool,tool_name,time,basis,root,coverageandchanged_files, and for each file inchangesitspath,operation,before,after,attribution,additionsanddeletions: what the page groups and counts the files by.hunks,windows,policy,window,outcome,gapsandoverlapsgo, and adiffthat wasavailablesaysunavailable, since its lines are not kept. - An execution record keeps
schema,id,tool,timeandoutcome: its own identity, the step it joins to, its place in time and how the call ended. - A provider body keeps its manifest without the literal strings, which are text of the body:
each segment loses its
litand keeps the rest, in order, and a segment left empty goes. So everypart,pieceandcopystays, a reader holding the skeleton checks it as it checked the body and accepts it or not as before, and a manifest with nosegmentskeeps none. Each other data part keeps the SHA-256 of its data insha256, which is the piece’s identity, or the one it had when it was too large to land.
- A
-
Its own closing line, with its own record count and digest.
A reader opens a .sk through gzip and reads it as any other Session Data. While a removal is in
progress, a landed file and its skeleton can both be there; read the landed file.
Producing Session Data
There is no Go interface to implement. An adapter is whatever lands Session Data in a storage root. Nothing above it reads a runtime field, so another runtime needs no code on the assembling side (see What travels).
An adapter writes:
- A header on every file, with
adapter, its name and contract version, anddialect, beside the other fields a reader requires. See Header. - Records with role-named identifiers and parts, one part for each piece of content, in source order. See Addressing a record.
- The closing line. The writer in
pkg/sessiondataadds it on close. - Files in the layout and under the names Storage Root shows. Each is written
once and made read-only, numbered one above the highest sequence already in the session, while
the session’s lock,
<session-id>/.lock, is held.
The built-in adapters are wired into cmd/asz by name, so a third-party adapter is a separate
program that writes the storage root. asz parse finds sessions by listing the root, and asz view
serves the chains the root holds, so such a root is assembled and shown like any other. The index is
not part of the contract. asz parse builds a session’s index from its landed files when the
session has none, and never reads past the index it has (see The index).
So a program that adds files to a session that already has an index removes its index/ directory.
That costs only a rebuild.
A dialect also has a glossary, keyed by the
dialect: what its runtime calls each name the model uses. cmd/asz gives the page and
asz glossary the Claude Code glossary, whatever dialect a conversation was read in.