Skip to main content
These are the items a Job Session Object carries: every agent step the worker observes becomes one thread item, posted via post_session_thread_item. The content array carries a brief human-readable rendering; metadata carries the structured detail.

General shape

content[].type and metadata.type are in different scopes — content-item shape vs. event shape — and don’t collide. Consumers (renderers, exporters, the future native widget) dispatch on metadata.type. The presence of metadata.type is the distinguishing mark vs. plain user-posted items, which have no metadata.type set.

Posting at most once

Every item the worker posts to a job thread carries metadata.post_id: pst_ followed by a ULID, minted once for the item and sent unchanged on every attempt to post it. The examples below leave it out. The API takes no idempotency key for a thread item, and post_id is how the worker avoids posting one twice anyway:
  • A 429 or a failed connect never reached the platform, so the post is retried, up to three times.
  • Any other transient failure — a 5xx, a body that is not JSON, a read timeout, a dropped connection — can follow an item that was stored. The worker waits 2 s, reads the thread’s 25 newest items, and posts again only if none carries the item’s post_id.
  • A read-back that cannot complete stops the post rather than risk a second copy: the item is not posted again, and the post fails as if its retries had run out. See Recovery → Network blips.
Two items with one post_id are one item. The read-back narrows duplicates but cannot rule them all out: an item the API stores after the read-back has looked, because its request outlived API Gateway’s 29-second answer, is stored twice, and so is one the read-back misses because more than 25 newer items arrived or the listing lagged past the 2 s wait. Both copies carry the same post_id, so a reader that shows one item per post_id shows each once. Items without one — a person’s messages, and the activity items on the worker thread — are never matched this way. The id is added after truncation, inside its 4 KiB of headroom.

Item types (per-thread)

text

The agent’s user-facing text output.
A text item carries no metadata beyond type — the full output is content[0].text. If an item exceeds the budget, the generic truncation path excerpts content[0].text and sets metadata.truncated (see the truncation rules below); there is no separate full-text copy for text items.

tool_call

The agent invoked a tool. Posted before the result so live tailing shows the call landing.

tool_result

The tool’s result for the matching invocation_id.
A tool_result carries no tool name — renderers fold a tool_call / tool_result pair by invocation_id and take the name from the tool_call. The brief is → <first output line> (N lines).

thinking

Internal reasoning blocks (Claude Code). The brief content.text shows the first ~120 characters; the full reasoning is in metadata.text.

status

Progress / status pings (e.g. “Compacting context”). Carries a machine-readable token.

attachment_unavailable

A status item (metadata.status: attachment_unavailable) posted when one or more of the current turn’s inbound file attachments couldn’t be materialized — a download that failed after 3 attempts (500 ms base, exponential backoff), or an inbox that’s unavailable for this activation entirely (see Job Session Object → Attachments). Posted before the turn runs, so the gap is visible ahead of the agent’s response; the agent is also told which attachments are missing in its own prompt.
metadata.files is capped at 20 entries — the item still has to fit the per-item posting budget — but content[0].text always reports the true count. reason is free text: either the literal inbox unavailable when the inbox itself couldn’t be created or escaped work_folder at activation (inbox_invalid, disarming every attachment for the rest of that activation), or the underlying download error’s own message otherwise. Both name and reason are abridged head-and-tail to 256 bytes at construction, so neither a client-supplied filename nor a verbatim error message can blow the item’s budget on its own; name also has its whitespace collapsed first, so an attacker-controlled name can’t inject a markdown heading into the agent’s own prompt — the same name is rendered into the ## Attachments section a coding agent reads (see Job Session Object → Attachments), not just into this audit item. A missing attachment never fails the thread. This item is the audit trail, not a gate — the turn still runs, and every attachment it names is also called out by name, type, size and path (or as unavailable) in the agent’s own prompt. See Filesystem Layout → The local inbox for where a successfully fetched attachment lands instead.

turn_end

Marker that the agent has finished its current turn. Useful for renderers to draw a turn separator and for monitoring.
input_tokens is the total input the model processed this turn (cached + uncached); input_tokens_cached is the portion served from the prompt cache. All three adapters (Claude Code, Codex, and Antigravity) report the same breakdown.

pending_prompt and pending_prompt_resolved

Used for the interactive-prompt round-trip. See Interactive prompts.

artifacts_published

Posted once per turn on a thread whose envelope carries an artifacts block (see Job Session Object → Artifacts) — even when nothing was published, so an empty outbox is visible rather than indistinguishable from the feature being off. Posted after the outbox is walked and every publishable file in it is uploaded as a file session object — text and binary alike, there’s no message-shaped artifact any more — immediately before that turn’s turn_end item, so anything reacting to turn_end always sees a complete artifact set. Each published file rides alongside the summary as a file content block:
Both lists are capped at 100 entries because a posted item has to fit the worker’s 60 KiB truncation budget and a large outbox would otherwise overflow it; counts always reports the true totals regardless of the cap. skipped[].reason is one of invalid_alias, unreadable, too_large, unsupported_type, or upload_failed — invalid_alias happens only when the prefix and directories leave no room for the name, or the prefix itself is invalid (see Job Session Object → Artifacts for how an alias is derived); too_large means the file exceeds the file object’s size cap, and unsupported_type means its content type was refused by the upload’s allow-list (see File session object for both limits). There’s no binary reason any more: every file uploads regardless of its content, so nothing is skipped for failing a text-encoding check. Chunking. A thread item caps file content blocks at 10 (too_many_files; see Post Session Thread Item), so a turn publishing more than 10 files spans several artifacts_published items: file blocks are chunked at 10 per item. A turn whose files fit in one item carries no part field at all; a turn that spans more than one item carries metadata.part: {index, total} (1-indexed) on every item, including the first — not only the follow-ons. Only the first item carries aliases, skipped, and error (when present) — every item carries counts with the true totals — and a follow-on item’s text reads "Published artifacts (part {index}/{total})". Metadata spill. aliases and skipped live in metadata, which sits outside the item’s own truncation ladder — there’s no excerpting a list of paths the way a text field gets trimmed. So before posting, the worker checks whether the first item — summary text, its metadata, and its first chunk of file blocks together — would already exceed the per-item posting budget. If it would, that first item is posted with no file blocks at all (summary and metadata only, still carrying aliases / skipped / error), and every file block instead moves into follow-on items, chunked at 10 per item starting at part.index: 2. Only the distribution changes this way; part.total always reflects however many items were actually emitted. An outbox that resolves outside work_folder is checked twice, at two different times, because the outbox is a directory inside the agent’s own writable workspace and the worker cannot assume it stays put for the length of a turn:
  • Caught at activation, before the first turn runs — the worker sets its in-memory artifacts spec to None instead of proceeding, and publishing is off for the thread’s entire current activation. No artifacts_published item is posted at all, for any turn, until the thread is next re-activated with a corrected outbox. An outbox_invalid warning lands in the worker’s log instead.
  • Caught at publish time — the activation-time check passed against the filesystem as it stood then, but the outbox path is re-resolved live on every publish, and the agent had the whole turn to rewrite it (for example, replacing the outbox directory with a symlink pointing outside work_folder). When that re-resolution lands outside the workspace, the artifacts_published item for that turn is posted, with aliases: [], skipped: [], and metadata.error set to the resolution failure.
Neither check is redundant with the other: the second one exists precisely because the first can’t see what happens during the turn it’s meant to guard, and a use-time re-check is the correct design for a directory an untrusted agent can rewrite. This item is an audit record, not the discovery mechanism. Find published artifacts by listing the session with prefix=<artifacts.prefix>, exactly as any other client would — never by reading this item.

policy_applied

Posted once before each turn on a guarded thread, naming the rule set in force. Doubles as an enforcement acknowledgement: its presence is what tells a reader this turn actually ran gated, rather than against a worker version too old to know about policy at all.
rules lists every rule in evaluation order — the worker’s own first, then the thread’s own — each tagged origin: worker | thread.

policy_denied

Posted once for every tool call a guarded policy denied.
detail is the denied call’s command or path, whichever the matched rule’s effect uses; when neither is populated — a codex file-change call, for instance, which carries no path at all (see Job Session Object → Policy and guarded permissions) — it falls back to the tool or call name instead.

Self-filter

Every item the worker posts carries the worker’s user_id (because BlobHub stamps the calling user on every post). The worker filters out items where user_id == self.user_id when consuming a thread — they are echoes of its own emissions and must not be re-fed to the agent. This has a documented consequence: a human posting from the same user account is also filtered. See Reference.

60 KiB truncation

The deployed server caps a posted thread item at 64 KiB (WORKFLOWS_THREAD_ITEM_MAX_SIZE = 64 * 1024, enforced with >=, so the largest item the server accepts is one byte under the cap). The worker truncates to a smaller 60 KiB budget before posting — the cap minus 4 KiB of envelope headroom (truncate.py: BUDGET = SERVER_ITEM_CAP_BYTES - ENVELOPE_HEADROOM_BYTES). The headroom exists because the two sides measure different objects: the worker measures {content, metadata} alone, while the server measures its own wrapper around that — ids, timestamps, the item’s alias — which adds bytes the worker never sees. A worker budget set at or above the server’s cap would let an item pass truncation, get posted, and then get rejected by the server anyway, failing the thread with THREAD_POST_FAILED.
After truncation, a posted item looks like:
The local_log field is relative to the profile’s own jobs/ directory. The full untruncated payload is always recoverable from the worker’s local disk.

See also