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

Handbook / Architecture · Checked against the source on 2026-10-05 · https://www.hbcode.ai/docs/output-and-previews

## 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**

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

- **Live row** (commandOutput.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 read** (run.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.
- **Completion** (item/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 row** (protocolOutput): 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**

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

- **Two truncation forms** (AggregateTruncation): 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 ledger** (StreamedOutputLedger): 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 replace** (settle_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.
- **Flag** (sourceIncomplete): 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 stopped | Recovery finds | Recovery does |
| --- | --- | --- |
| After the pending row, before the bytes | A 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 write | A 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 commit | A 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 explains | No pending row. The file is longer than the catalog. | Cuts the file back to the catalog length. |
| And the file lost bytes | The 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**

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

- **Output row** (commandOutput.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.
- **Read** (run.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.
- **Slice** (MAX_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 copy** (codex_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.

| Question | Codex | Claude |
| --- | --- | --- |
| Bytes while the command runs | Desktop artifact file, appended per delta | None. The output row appears with the result. |
| Bytes after the command | Codex’s capture file, read through the app-server | The tool_result in Claude’s transcript |
| Row source | Desktop artifact while live, pointer to Codex when settled | Pointer to the transcript, live and saved |
| Cost of one range read | One file range, or one app-server request | One host start and one full transcript load |
| Can be incomplete | Yes, when the desktop or Codex kept less than the command printed | No, the result is saved whole |

- [The agent host](https://www.hbcode.ai/docs/claude-agent-sdk.md#run-loop): The host process, its requests, and the one-shot form that transcript reads use.
- [Where each agent stores data](https://www.hbcode.ai/docs/storage.md#ownership): The Claude folder, the Codex profile home, and the tables HB Code owns.

## HTML documents in a sandbox

**Each HTML preview is its own site, and the resource root is its top folder**

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

- **Resource root** (open_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 check** (ptc-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.
- **Sandbox** (sandbox 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.
- **Phones** (files.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.

- [Documents the agent writes](https://www.hbcode.ai/docs/tools/agent-tools.md#documents): Native agent authoring and the HTML file viewers.
- [HTML documents on iOS](https://www.hbcode.ai/docs/ios/media.md#documents): A custom URL scheme handler streams verified chunks into WebKit.
- [HTML documents on Android](https://www.hbcode.ai/docs/android/media.md#documents): A WebView profile and an .invalid origin for every preview.

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

- **Chunk read** (files.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 pin** (contentVersion): 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 frame** (DEVICE_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.

- [Code with Runestone and Tree-sitter](https://www.hbcode.ai/docs/ios/media.md#code): Tree-sitter grammars, shared prepared languages, and output rendering by size on the iPhone.
- [TextMate grammars shared with the desktop](https://www.hbcode.ai/docs/android/media.md#code): How the Android code viewer reuses the desktop’s Shiki grammar inventory.
