> ## Documentation Index
> Fetch the complete documentation index at: https://docs.blobhub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Thread Items

> Content and metadata shape for items the worker posts

These are the items a [Job Session Object](/worker/session-agent-harness/thread-object) carries:
every agent step the worker observes becomes one thread item, posted via
[`post_session_thread_item`](/blob-types/workflow/operations/post-session-thread-item). The
`content` array carries a brief human-readable rendering; `metadata` carries the structured
detail.

## General shape

```yaml theme={null}
content:
  - type: text                       # content-item type (text | json | image later)
    text: <brief human-readable rendering>
metadata:
  type: text | tool_call | tool_result | thinking | status | turn_end | pending_prompt |
        pending_prompt_resolved | artifacts_published | policy_applied | policy_denied
  # plus type-specific fields below
```

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

## Item types (per-thread)

### `text`

The agent's user-facing text output.

```yaml theme={null}
content:
  - type: text
    text: |
      Looks like the issue is in `parseToken` — it doesn't handle the new prefix.
metadata:
  type: text
```

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.

```yaml theme={null}
content:
  - type: text
    text: "Bash → npm test"
metadata:
  type: tool_call
  tool:
    name: Bash
    invocation_id: tu_01J...
    input:
      command: npm test
      description: Run the test suite
```

### `tool_result`

The tool's result for the matching `invocation_id`.

```yaml theme={null}
content:
  - type: text
    text: "→ ✓ src/parser.test.ts (12 ok) (3 lines)"
metadata:
  type: tool_result
  tool:
    invocation_id: tu_01J...
    is_error: false
    output: |
      ✓ src/parser.test.ts (12 ok)
      Test Files  1 passed (1)
      ...
```

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

```yaml theme={null}
content:
  - type: text
    text: "[thinking] Considering the user's request and looking at the parser code..."
metadata:
  type: thinking
  text: "Considering the user's request and looking at the parser code, the prefix handling..."
  full_text_length: 1842
```

### `status`

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

```yaml theme={null}
content:
  - type: text
    text: "Compacting context..."
metadata:
  type: status
  status: compacting_context
  detail: "Reducing 38k → 12k tokens"
```

### `turn_end`

Marker that the agent has finished its current turn. Useful for renderers to draw a turn separator
and for monitoring.

```yaml theme={null}
content:
  - type: text
    text: "Turn complete"
metadata:
  type: turn_end
  stats:
    input_tokens: 12480
    input_tokens_cached: 9600
    output_tokens: 1322
    duration_ms: 8421
```

`input_tokens` is the total input the model processed this turn (cached + uncached);
`input_tokens_cached` is the portion served from the prompt cache. Both adapters (Claude Code and
Codex) report the same breakdown.

### `pending_prompt` and `pending_prompt_resolved`

Used for the interactive-prompt round-trip. See
[Interactive prompts](/worker/session-agent-harness/interactive-prompts).

### `artifacts_published`

Posted once per turn on a thread whose envelope carries an `artifacts` block (see
[Job Session Object → Artifacts](/worker/session-agent-harness/thread-object)) — **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, immediately **before** that turn's
`turn_end` item — so anything reacting to `turn_end` always sees a complete artifact set.

```yaml theme={null}
content:
  - type: text
    text: "Published 3 artifact(s), skipped 1"
metadata:
  type: artifacts_published
  aliases:                                                                             # capped at 100
    - projects/p1/items/i1/runs/1/artifacts/docs/plan.md
    - projects/p1/items/i1/runs/1/artifacts/src/parser.py
  skipped:                                                                              # capped at 100
    - { path: out.bin, reason: binary }
  counts: { published: 3, skipped: 1 }
```

Both lists are capped at 100 entries because a thread item is capped at 350 KB and a large outbox would
otherwise overflow it; `counts` always reports the true totals regardless of the cap. `skipped[].reason` is
one of `binary`, `too_large`, `invalid_alias`, `unreadable`, or `upload_failed`.

A `source` 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 `source`. 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.

```yaml theme={null}
content:
  - type: text
    text: "Policy in force: 3 rule(s), default allow"
metadata:
  type: policy_applied
  rules:
    - { id: confine_writes, origin: worker, action: deny }
    - { id: never_force_push, origin: worker, action: deny }
    - { id: destructive_git, origin: thread, action: deny }
  default: allow
```

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

```yaml theme={null}
content:
  - type: text
    text: "Blocked by policy rule 'confine_writes': /etc/passwd"
metadata:
  type: policy_denied
  rule: confine_writes
  effect: write
  detail: /etc/passwd
```

`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](/worker/session-agent-harness/thread-object)) — 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](/worker/session-agent-harness/reference).

## 350 KB truncation

The worker enforces a **350 KB** budget (`350_000` bytes) on each serialized thread item and
truncates large payloads before posting.

```
1. Always write the full untruncated payload to
   jobs/{job_id}/threads/{alias}/logs/thread.log before posting.
2. Build the candidate item dict (content + metadata).
3. budget = 350 KB
   if json.dumps(item) <= budget: post; done.
4. Identify the largest truncatable string fields, in order:
     metadata.tool.output, metadata.tool.input,
     metadata.text, content[0].text
   For each, replace with: <head 4 KB> + "…[truncated <N> bytes; see <local-log-path>]…" + <tail 2 KB>.
   Set metadata.truncated = true.
   Append the field name to metadata.truncated_fields.
   Set metadata.local_log = "<job_id>/threads/{alias}/logs/thread.log".
5. If still > budget after truncating all fields above, drop them entirely (keep markers + field list).
6. If still > budget (very rare), transition the thread to failed with THREAD_ITEM_TOO_LARGE.
```

After truncation, a posted item looks like:

```yaml theme={null}
content:
  - type: text
    text: "→ added 1452 packages in 38s (4210 lines)"
metadata:
  type: tool_result
  truncated: true
  truncated_fields: [tool.output]
  local_log: session_agent_harness-ses_01J.../threads/my-feature-x/logs/thread.log
  tool:
    invocation_id: tu_01J...
    is_error: false
    output: |
      <first 4 KB>
      …[truncated 412992 bytes; see <local-log-path>]…
      <last 2 KB>
```

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

* [Job Session Object](/worker/session-agent-harness/thread-object) — the object these items belong to.
* [Handoff](/worker/session-agent-harness/thread-handoff)
* [Interactive](/worker/session-agent-harness/interactive-prompts)
* [`post_session_thread_item`](/blob-types/workflow/operations/post-session-thread-item)
* [`list_session_thread_items`](/blob-types/workflow/operations/list-session-thread-items)
