Skip to article
HB CodeDocsGet the app

HandbookArchitecture

Command output, documents, and previews

Where command output lives for Codex and for Claude and how a viewer reads it in ranges, how HTML documents run in a sandboxed origin on three platforms, how web pages become Markdown, and how files travel in chunks.

Checked against the source on 5 October 2026

On this page

Codex command output as a desktop artifact

The two agents keep command output in different places. This section is about Codex. A Codex run is the only thing that writes a desktop artifact: one file per command in the codex-command-outputs folder under the app’s local data folder, named with 32 hexadecimal digits and the extension .output. The catalog table codex_command_outputs maps the key (run_id, command_id) to the file, its byte length, its generation, and the flags complete and source_incomplete. The owner is the base session, and a read from another session fails with a conflict.

Codex only: command output is pulled from the desktop while it runs and from Codex once it settles
Codex only: command output is pulled from the desktop while it runs and from Codex once it settlesOne Codex command over time. Codex streams output deltas and writes its own capture file, the desktop appends every delta to its artifact, and the timeline row only receives the size and generation. An open viewer reads up to 256 KiB at a time from the artifact. At item/completed, the desktop compares its bytes with Codex’s aggregatedOutput. In this case the aggregate is not truncated and its bytes differ, so it replaces the artifact as generation 2, a read sent for generation 1 gets a conflict, and the viewer refreshes the row. When the row settles it points at Codex’s capture file, and reads go through the desktop to thread/commandOutput/read.
Codexruns the command
Desktop artifact
capture file
output deltas with bytes
completed, with its aggregate
generation 1, each delta appended
generation 2
different bytes, not truncated: generation 2
Timeline row
size and generation, no bytes
settles, now points at Codex
Viewer
closed, no reads
reads up to 256 KiB
generation 1 is gone: conflict, refresh the row
not read again
reads Codex’s capture through the desktop
item/started
item/completed
row settles
  • Live rowcommandOutput.artifact

    The desktop appends each output delta to its artifact before the row changes, then sends runId, byteLength, generation, complete, and sourceIncomplete at most every 100 ms per command. No output byte enters the timeline.

  • Range readrun.chatCommandOutputRead

    Only an open viewer reads, 1 byte to 256 KiB per request, and appSessionId binds the read to the owning base session. A generation the artifact no longer holds answers with a conflict, and the viewer refreshes the row before it reads again. Phones call it through the relay, and the desktop viewer calls the same read directly.

  • Completionitem/completed

    The desktop compares its bytes with Codex’s aggregatedOutput. The same bytes mark the artifact complete in place. Different bytes with no truncation replace it as the next generation. A truncated aggregate never replaces a longer stream, which the next figure shows.

  • Settled rowprotocolOutput

    Once thread/items/list returns the command, the row points at the capture file Codex wrote while it ran, and an open desktop viewer follows it by the unchanged block id. The desktop forwards reads as thread/commandOutput/read and checks the echoed identity. capturedComplete is false when Codex kept no full capture.

Codex sends every output byte in the delta stream and keeps a bounded aggregate of at most 1 MiB for item/completed. The desktop therefore treats the aggregate as a check and not as the truth. It counts the bytes and the deltas of the stream in a ledger and uses that count to decide whether the stored stream holds everything.

Codex only: a truncated aggregate never replaces a longer stream
Codex only: a truncated aggregate never replaces a longer streamSizes to scale for one Codex command that printed 3 MiB. The delta stream carried all 3 MiB. Codex’s aggregatedOutput keeps 1 MiB: a 512 KiB head, a marker that says 2,097,152 bytes omitted, and a 512 KiB tail. Three outcomes follow at item/completed. When the desktop counted all 3 MiB in the stream, the artifact keeps the stream as generation 1 and sourceIncomplete stays false. When the desktop counted only 2.4 MiB, the artifact still keeps the longer stream as generation 1 and sourceIncomplete is true. When storing the stream failed, the aggregate becomes generation 2 and sourceIncomplete is true.
Delta streamevery byte
all 3 MiB, in order
AggregateCodex keeps 1 MiB
head
... 2,097,152 bytes omitted ...
tail
The desktop artifact after item/completed
Stream provenledger counts 3 MiB
stream kept
generation 1, sourceIncomplete false
Stream longerledger counts 2.4 MiB
stream kept, 2.4 MiB
not seen
generation 1, sourceIncomplete true
Stream failedan append failed
head
... 2,097,152 bytes omitted ...
tail
generation 2, sourceIncomplete true
0 MiB
1 MiB
2 MiB
3 MiB
  • Two truncation formsAggregateTruncation

    Codex unified exec keeps an equal head and tail of 1 MiB in total and puts “... N bytes omitted ...” between them. Codex classic exec cuts the aggregate at 1 MiB with no marker. The desktop detects the second form when the stream delivered more bytes than the aggregate holds, with 8 bytes of slack per delta.

  • Stream ledgerStreamedOutputLedger

    The desktop counts the bytes and the deltas that Codex sent for the command. The stream is proven complete when the count plus 8 bytes reaches head plus omitted plus tail. An aggregate without a marker can never prove it.

  • Keep or replacesettle_truncated_snapshot_from_path

    The stream stays when it is proven complete or when the artifact holds more bytes than the aggregate. The aggregate replaces the artifact as the next generation only when the stream is unproven and not longer, or when an append failed.

  • FlagsourceIncomplete

    True means that the viewer shows less than the command printed or that the desktop cannot prove otherwise. Only a proven complete stream leaves it false.

Each append is written ahead. The desktop inserts a row in codex_command_output_pending_appends with the expected offset, the length, the generation, and a SHA-256 digest of the bytes. It then writes the bytes to the file and commits, which moves byte_length in the catalog and removes the pending row. Every later append, read, completion, and the sweep at start run the same recovery first.

The app stoppedRecovery findsRecovery does
After the pending row, before the bytesA pending row. The file is as long as the catalog says.Removes the pending row. The delta is lost and the file is unchanged.
In the middle of the writeA pending row. The file is longer than the catalog and shorter than the pending end.Cuts the file back to the catalog length and removes the pending row.
After the bytes, before the commitA pending row. The file ends at the pending end.Compares the SHA-256 digest of the tail. A match syncs the file and commits the append. A mismatch cuts the tail and reports a storage error.
With bytes that no row explainsNo pending row. The file is longer than the catalog.Cuts the file back to the catalog length.
And the file lost bytesThe file is shorter than the catalog, or it is gone.Reports an error. At item/completed the aggregate replaces the artifact as the next generation.

After a failed append the row shows “Command output is unavailable because this device could not store it.”, and the desktop drops the later deltas of that command. At item/completed a command with an aggregate takes the aggregate as its artifact. A command without one keeps that row, because the partial file must not look complete.

Deleting a session removes its catalog rows in the delete transaction and then removes the files. At start the desktop removes abandoned stage files. After the Codex warm-up it recovers every pending append and sweeps the folder: a file that no catalog row names is removed only when it is older than 1 hour, because a newer file can belong to a command that is still running.

Claude command output stays in the transcript

A Claude session has no desktop artifact. When a Bash tool_result arrives, the desktop adds an output row that carries a Protocol pointer to the saved result and no bytes. The exit code is 0 when the result is not an error. For a failed command the desktop reads it from the first line of the result, which Claude writes as Exit code N.

A Claude command’s output has one copy, in the transcript, and every read loads that transcript whole
A Claude command’s output has one copy, in the transcript, and every read loads that transcript wholeOne Claude Bash command over time. While the command runs, the timeline holds the call row only. When the tool_result arrives, Claude Code saves the whole result in its transcript, and the timeline gets an output row that holds a Protocol pointer and no bytes. The viewer opens later. For each range read the desktop starts a new agent host, the host reads the whole transcript through the SDK and exits, and the desktop returns a slice of at most 256 KiB. A 3 MiB output needs at least 12 such reads.
Timeline rowno output bytes
Claude Coderuns Bash
Transcriptowned by Claude Code
call row only
output row with a Protocol pointer, no bytes
command runs
whole result saved: 3 MiB
tool_result
Agent hosttoolOutput
Viewer
closed, no reads
a new host process for each read
loads the whole transcript
bytes 0 to 262,143
loads it all again
bytes 262,144 to 524,287
Bash starts
result arrives
first read
next read
A Codex command also has a desktop artifact file. A Claude command has none. The transcript is the only copy, and each 256 KiB slice costs a full transcript load.
  • Output rowcommandOutput.protocolOutput

    The pointer names the transcript in threadId (the Claude session ID, or the subagent ID), the user message that holds the result in turnId, and the tool_use_id in itemId. updatedAtOrdinal is 1, byteLength is the length of the result text, and capturedComplete is true. The live row and the saved row carry the same pointer.

  • Readrun.chatCommandOutputRead → toolOutput

    The desktop starts a host process for this one request. The host loads every message of the conversation or the subagent with getSessionMessages or getSubagentMessages, returns the first tool_result with that tool_use_id, and exits.

  • SliceMAX_COMMAND_OUTPUT_RANGE_BYTES

    The desktop cuts the requested range from the whole result text. One request asks for 1 byte to 256 KiB, availableBytes is the full length, and complete is always true. The phone viewers ask for consecutive ranges, as drawn. The desktop viewer asks for overlapping pages and cuts them at line ends.

  • No desktop copycodex_command_outputs

    Only a Codex run writes a desktop artifact. For Claude, no output byte enters appdata.db or the artifact folder. A result that the transcript no longer holds answers “This command output is not in the saved Claude conversation.”

The read uses the same method as for Codex, run.chatCommandOutputRead with a Protocol source, and the same limit of 256 KiB per answer. The cost differs. Each call starts a one-shot agent host in the main Claude folder, sends toolOutput, and waits for the host to exit. The host loads all messages of the conversation, or of the subagent when the pointer names one, and returns the first tool_result with the tool_use_id. The desktop then slices the text. A result that is not plain text becomes text first: a text block keeps its text, a tool_reference block becomes the tool name, and any other block becomes its type in square brackets.

Only Bash gets an output row. Every other Claude tool has one row: the call row, completed in place with the result text. For both agents, the detail view of such a tool row decodes the result with decode_tool_output_container. It unwraps JSON envelopes to a depth of 6, and it treats a nested object as command transport only when the object has transport fields such as stdout, stderr, or wall_time_seconds.

QuestionCodexClaude
Bytes while the command runsDesktop artifact file, appended per deltaNone. The output row appears with the result.
Bytes after the commandCodex’s capture file, read through the app-serverThe tool_result in Claude’s transcript
Row sourceDesktop artifact while live, pointer to Codex when settledPointer to the transcript, live and saved
Cost of one range readOne file range, or one app-server requestOne host start and one full transcript load
Can be incompleteYes, when the desktop or Codex kept less than the command printedNo, the result is saved whole

HTML documents in a sandbox

Each HTML preview is its own site, and the resource root is its top folder
Each HTML preview is its own site, and the resource root is its top folderLeft, the desktop disk. Right, what a preview page at /docs/report.html asks for. Each request maps onto the same path under the resource root, so relative imports and fetches load, a path cannot climb above the root, a symlink that leads outside is refused, and a hidden file under the root such as .env is refused too. Below, the sandbox gives the page no access to the app, and a navigation message is the only thing the app acts on.
sandbox
no access to the app’s page or storage
hbcode.html.open
The appopens a file under the root or a web page
Desktop disk
~/Pictures/logo.png
~/.ssh/id_ed25519
~/work/shop
resource root
docs/report.html
src/chart.js
data/sales.json
assets → ~/Pictures
.ssh/id_ed25519
.env
leaves the root
not there, 404
hidden file, 404
Preview page
ptc-document://<preview-id>.localhost
no URL reaches above the root
/
/docs/report.html
the page itself
/src/chart.js
import "../src/chart.js"
/data/sales.json
fetch("../data/sales.json")
/assets/logo.png
<img src="../assets/logo.png">
/.ssh/id_ed25519
fetch("../../../.ssh/id_ed25519")
/.env
fetch("../.env")
  • Resource rootopen_html_preview_command

    The project folder when it contains the document, otherwise the document’s own folder. A filesystem root, the home folder, and a parent of the home folder never serve as a shared root: a document that sits directly in such a folder can read only itself. The caller generates the preview UUID, and the desktop refuses to bind that ID to a second document.

  • Path checkptc-document://<preview-id>.localhost/<path>

    The handler refuses a decoded path with .., a backslash, a colon, or NUL, serves index.html for a folder, and answers 404 when the canonical path is missing or leaves the root. It also answers 404 for a file or folder whose name starts with a dot, except inside the hidden folders that contain the document.

  • Sandboxsandbox allow-scripts

    Scripts, modules, and fetch run in an opaque origin with no access to the app’s page or storage. A hbcode.html.open or hbcode.html.ready message from that frame is the one thing the app acts on, and only for a file under the root or an http(s) page.

  • Phonesfiles.readHtmlPreviewResource

    The iPhone app at ptc-document://<preview-id>.localhost and the Android app at https://ptc-<preview-id>.invalid resolve the same paths through the desktop, in verified chunks of up to 4 MiB, with no script bridge into the app.

The protocol answers OPTIONS with 204, serves GET and HEAD, answers every other method with 405, and sets Cache-Control no-store and X-Content-Type-Options nosniff. Every refused or missing path answers 404. The file on disk stays unchanged, and the desktop changes each HTML response in two places. It inserts a style element with the id hb-theme at the end of the head, which gives the page the app theme as --hb-* custom properties. A document with no head element, or one that is not UTF-8, gets no theme. It then appends navigation.js after the document end. That script posts hbcode.html.open to the parent and blocks every scheme other than ptc-document and http(s). Local links inside a document enter the preview history, web links open in the web preview, and Reload or a file change refreshes the rendering.

The read scope is smaller than the resource root. A file or folder whose name starts with a dot, such as .env, .git, or .ssh, is refused. The hidden folders that contain the document are the exception: a site built into .vitepress/dist still reads its own files. A project folder that is the filesystem root, the home folder, or a parent of the home folder is never the root. The root is then the document’s own folder, and when that folder is also one of these, the document reads only itself.

Web pages as Markdown

A web link opens as a Markdown rendering instead of a live page. The WebView extracts the article content and converts it with a GFM-aware Turndown pipeline, the Rust side converts with quick_html2md for content that never reaches the WebView, and the Browser Bridge has a third, hand-written DOM-to-Markdown renderer whose output the desktop stores as capturedBrowserMarkdown and wraps with the same title header. Three converters, one target format.

File reads and transfers

files.readContent returns a text document of at most 2 MiB. A larger file fails with “Text preview is too large”. Its request carries projectDirectory, path, and optional encoding and allowExternalFile, and the desktop accepts the encoding and ignores it. The request has no chunk offset or media fields, because text and binary requests stay separate. files.readBinary and files.readMediaRendition read sequential chunks from offset zero. Each response carries offset, nextOffset (null at EOF), dataBase64, byteCount, contentVersion, modifiedAt, and mimeType, and renditions add originalByteCount. Neighbouring limits: media rendition sources up to 1 GiB, stream transcodes up to 256 MiB with 40-second timeouts, media range reads of 1 MiB, and chat-attachment uploads in 1 MiB chunks, at most 2,048 of them.

A file of any size travels as independent 4 MiB reads tied to one version
A file of any size travels as independent 4 MiB reads tied to one versionA 9,961,472-byte file drawn to scale is read in three requests at offsets 0, 4,194,304, and 8,388,608. The desktop opens the file for every request and keeps nothing in between, while the phone pins the version and size from the first answer. When the file is saved during the transfer, the next answer carries a new version and the phone discards the whole read.
report.pdf9,961,472 bytes
4 MiB
4 MiB
1.5 MiB
0
4,194,304
8,388,608
Desktopopen, read, answer
keeps nothing between requests
Phonefile unchanged
pins version A and 9,961,472 bytes
version A again
nextOffset null
Phonefile saved midway
version A
file saved
version B
File changed while loading. Open it again.
  • Chunk readfiles.readBinary

    Each request names the path and an offset. The desktop opens the file, reads up to 4 MiB there, and answers with the next offset, the total byteCount, and a contentVersion. HTML preview resources and media renditions use the same reader.

  • Version pincontentVersion

    For files.readBinary it hashes the path, size, and modification time each time a read opens the file. Both phones keep the first answer’s version and size and discard the whole read when a later answer differs. A file that shrank below the offset gets the same message from the desktop.

  • Relay frameDEVICE_LINK_MAX_FRAME_BYTES

    A 4 MiB chunk is about 5.3 MiB of base64, so every answer stays well inside the relay’s 32 MiB frame limit, and the file itself has no size cap.

Syntax highlighting

Source previews on the desktop use Shiki grammars, and each phone app highlights with its own engine.