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 carriesmetadata.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.
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.
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.
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:
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
Noneinstead of proceeding, and publishing is off for the thread’s entire current activation. Noartifacts_publisheditem is posted at all, for any turn, until the thread is next re-activated with a correctedoutbox. Anoutbox_invalidwarning 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, theartifacts_publisheditem for that turn is posted, withaliases: [],skipped: [], andmetadata.errorset to the resolution failure.
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’suser_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.
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
- Job Session Object — the object these items belong to.
- Handoff
- Interactive
post_session_thread_itemlist_session_thread_items

