Skip to main content
A Job Session Object is the unit of work a session_agent_harness section drives — a thread-typed session object whose metadata describes a workspace, an agent, and a control-channel state. The user prepares it; the worker watches the control channel, takes over when it goes pending, and drives one agent in the workspace. This page covers the static shape and ownership of that object; the lifecycle dynamics live in Handoff.

Wire metadata shape

Inside the thread envelope (value.thread.metadata), the worker reads and writes these blocks:
The envelope is created and updated via upload_session_object on the thread’s alias.

Required vs optional

  • workspace.work_folder — required. An absolute path to the repository on the worker host.
  • agent.type — required. One of claude_code, codex, or antigravity. The corresponding CLI must already be installed and signed in on the worker host — see Install.
  • Everything else under agent.* — optional. Omitted fields fall through the settings tiers below.
  • artifacts — optional. Absent means the run publishes nothing. When present, both prefix and outbox are required; a block missing either still publishes nothing, but warns (artifacts_block_incomplete, naming the missing field) rather than staying silent. See Artifacts below.
  • attachments — optional. Unlike artifacts, there’s no on/off switch here: the worker always resolves an inbox directory and always materializes any file blocks a thread item carries. An absent block, or an inbox that isn’t a string, just defers to the next tier — the worker’s own config, then the codebase default .blobhub/inbox. See Attachments below.
  • policy — optional, except when agent.permissions resolves to guarded, where it becomes required: a guarded thread with no policy block fails activation with POLICY_MISSING. See Policy and guarded permissions below.

Field ownership and the control channel

The protocol relies on a strict ownership split; each side never modifies fields the other owns. instance.state is the control channel. The user drives the thread with it:
  • set pending to hand the thread to the worker,
  • set completed to stop the worker cleanly,
  • reset failed → pending to retry a failed thread.
The worker writes instance.state only to the values active and failed. It never writes completed (that is user-set), and it never writes a resume pointer or an error block to the wire. Failure detail lives locally and surfaces server-side as a thread_failed item in the worker activity thread — not on this envelope.

Agent settings resolution

Each agent setting resolves through three tiers, with the most specific winning per field:
  1. the thread’s own agent.* on this envelope,
  2. the worker’s agents.<type>.* in config.yaml,
  3. the codebase default.
Only permissions has a non-empty codebase default (approval); executable falls back to claude for claude_code, codex for codex, and agy for antigravity. On antigravity, model and effort are mutually exclusive — most published model slugs carry their own effort suffix and the CLI refuses both at once, so the worker sends whichever is set and model wins when both are. permissions controls the approval gate: autonomous runs without a gate; approval routes every sensitive action to an interactive prompt a human answers from the web UI, denying it if no human is reachable; guarded routes sensitive actions to a rule set first, falling back to that same prompt only when a rule — or the absence of one — says ask. See Policy and guarded permissions below, and Configuration for where the worker config tier is set.

Artifacts

An optional artifacts block turns on a per-turn publish step: once a turn’s work completes, and just before its turn_end item is posted, the worker walks a local output folder and uploads every file in it as a file session object, one per file, under an alias prefix you choose.
Renamed from source (breaking): an envelope still carrying the old source key is treated as an artifacts block missing outbox entirely — it warns artifacts_block_incomplete naming outbox and publishes nothing. Both fields are required whenever the block is present — there is no default for either, so “not configured” never silently becomes “publish something.” An absent artifacts block is the ordinary feature-off case and stays silent; a block that’s present but missing one field still publishes nothing, but warns (artifacts_block_incomplete, naming the missing field), because that shape means someone tried to turn publishing on and got it wrong. What gets published. There is no diff, manifest, or file-change detection: the folder’s contents at publish time are the artifact set, by definition. The worker walks outbox recursively (files only, symlinks not followed, its own outbox .gitignore excluded, its own inbox subtree excluded — see Attachments above — sorted for determinism) and derives each file’s alias from its path relative to outbox. A relative path that’s already a valid alias publishes at <prefix><relative path> unchanged, and wins over any derived alias. Otherwise, every character outside the alias grammar is replaced with _, and a tag — - plus the first 6 hex characters of the SHA-256 of the file’s original relative path — is inserted before the extension (at the end for a name with no extension, or a dotfile). Because the tag comes from the file’s own path, a file gets the same alias every time the outbox is published, whatever else is in it, so republishing a file overwrites its object in place; only if that exact alias is somehow already taken does it get a further -2, -3, … after the tag. A name that would push the alias past the 256-character limit is shortened, keeping its extension — directory names are never shortened. The object’s name always keeps the file’s original basename regardless of what its alias became — docs/Chapter 2 (final).png publishes to <prefix>docs/Chapter_2__final_-dc4ad1.png with name Chapter 2 (final).png. Every file uploads as a file object, text and binary alike — there’s no content check that skips one kind or the other. A file is skipped, not failed, when the prefix and directories leave no room for the name, or the prefix itself is invalid (invalid_alias), it can’t be opened (unreadable), it exceeds the file object’s size cap (too_large; see File session object for the limit), its content type is refused by the upload’s allow-list (unsupported_type), or the upload itself fails after retries (upload_failed). The outbox lives inside work_folder, and the worker creates it (with parent directories) the moment a thread carrying an artifacts block activates, so it exists before the agent’s first turn looks for it. It is also self-ignoring — the worker writes a .gitignore containing a single * into it. See Filesystem Layout → The local outbox for the full physical picture and why the outbox cannot live outside work_folder. Reporting. One artifacts_published item is posted on every turn of an activation whose artifacts block passed the activation-time check above — including a turn where the outbox was empty, so “nothing to publish” is visible rather than indistinguishable from the feature being off. That qualifier matters: when outbox fails the activation-time check instead, no item is posted on any turn of that activation — see the outbox row above and Thread Items for both of outbox’s escape triggers and what each one produces. 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. Neither artifacts nor its outcome is mirrored into the local thread.yaml: the worker re-reads the block from the envelope on every activation and every recovery, so a thread that was publishing before a worker restart keeps publishing afterward.

Attachments

Any thread item — an ordinary message, or the answer to a pending prompt — can carry file content blocks referencing files already uploaded to the session, interleaved with the item’s text in whatever order they were composed. Before such an item reaches the agent, the worker downloads every file it references and writes it to a local inbox directory, and it preserves the item’s block order when it builds the turn: text and files are handed to the agent as native multimodal input where the harness accepts it. Claude Code and Codex receive images natively, each positioned where it appears in the message rather than as a trailing batch, so the model sees a picture at the point the surrounding text refers to it. Antigravity does not — its machine input format accepts text blocks only, so every attachment on an antigravity thread reaches the agent by path. Where a file is delivered by path instead of natively, the prompt carries an inline [name] reference at that same position alongside a list of on-disk paths. The inbox copy is written regardless (see Filesystem Layout → The local inbox and Thread Items for the mechanics), so a non-image file — or any file a harness can’t take natively — stays reachable by path.
Resolution is three-tier, most specific first: this thread’s own attachments.inbox → the worker’s own attachments.inbox in config.yaml (see Configuration) → the codebase default .blobhub/inbox. Unlike artifacts, there is no opt-out here — every tier resolves to some directory, and the worker always creates and uses one, whether or not this block is present. An explicit empty string (attachments: {inbox: ""}) is treated the same as an absent inbox at that tier: it falls through to the next one silently, with no warning — this is distinct from the malformed-shape case below, which does warn. An absent attachments block is therefore the ordinary case, not a feature switch: resolution falls through to the next tier silently, with no warning. A block that is present but the wrong shape entirely — a string or list where the mapping belongs — warns attachments_block_malformed (naming the type found) and falls through the same way; an inbox value that is present but isn’t a string is swallowed silently rather than warned, and resolution still falls through. The resolved path must resolve inside work_folder, checked the same way artifacts.outbox is — containment failure raises the same escape error, warns inbox_invalid, and the inbox is disarmed for the thread’s entire current activation: turns still run, but every attachment on every item reports unavailable (inbox unavailable) rather than blocking the thread. See Thread Items → status for the attachment_unavailable item this produces. Like artifacts, an attachments block has no local counterpart at all — see “Wire ↔ local mapping” further down this page.

Policy and guarded permissions

agent.permissions: guarded (see Agent settings resolution above) routes a thread’s sensitive tool calls to a rule set instead of a human, falling back to the same interactive prompt approval uses whenever a rule — or the absence of one — says ask. The rule set comes from two places, evaluated in a fixed order:
  1. The worker’s own policy.rules, set once in config.yaml (see Configuration → policy) — evaluated first, always.
  2. This thread’s own policy block — evaluated second, and required for any guarded run: a guarded thread with no policy block fails activation with POLICY_MISSING rather than running ungated under a mode that promises enforcement.
Evaluation order — first match wins: the worker’s rules, in order, then this thread’s rules, in order, then this thread’s default. A machine owner’s deny can therefore never be relaxed by anything a session supplies, while a thread can still be stricter than the worker’s own baseline. A malformed policy — an effect / action outside the vocabulary above, or an invalid default — fails activation with POLICY_INVALID rather than silently matching nothing; so does a policy block that is present but the wrong shape (an object expected where something else was sent), regardless of permissions — this one check runs on every activation, whether or not the thread is guarded.

What a rule can see, per harness

The effect vocabulary (write | exec | read | network) is portable, but the three harnesses do not see the same calls, and a rule author has to know it before writing a confinement rule: Anything unrecognised — every mcp__* tool, and codex’s own permission-mode approval request — maps to no effect and falls through to default. That is deliberate: inventing an effect for a call its author never considered would make a rule fire on something it was never written against. On antigravity that gap is bigger than a leftover, and worth planning for: 18 of the 57 tools the CLI advertises are deliberately left unmapped, because none of them has a single honest effect. Most are bookkeeping (ask_*, list_*, manage_*) or control flow (wait, finish). Four are not: call_mcp_tool does whatever its server offers, and define_subagent, invoke_subagent and browser_subagent can each do anything the parent agent can. All four reach your policy’s default and nothing else — so on a sensitive workspace, default: allow is a wider grant on this harness than the effect table above suggests. What actually gates a codex file change is whether the sandbox refuses the write — not a path string. Upstream codex-rs (openai/codex @ 5825699) generates item/fileChange/requestApproval from two different triggers, both ending in the same request this worker’s gate answers:
  1. Admission-time. assess_patch_safety (core/src/safety.rs) checks the patch’s target against the sandbox’s writable roots before anything runs; a target outside them escalates immediately, without the write being attempted at all.
  2. Runtime denial. apply_patch uniquely retries outside the sandbox when the sandbox refuses a write it is already attempting (wants_no_sandbox_approval, runtimes/apply_patch.rs) — which also escalates into the same approval request, even for a target admission had already cleared as in-workspace. The case named upstream is a path that is a hard link out of the writable roots: the string looks inside work_folder, and isn’t.
So on a normal, sandboxed deployment — codex has a sandbox backend on macOS and Linux — the gate receives exactly the writes codex’s own sandbox refused, by either route. That is a stronger claim than “outside the workspace”: it also catches disguised escapes, like the hard-link case, that a simple path-string check would have scored as safe. This is a point in the rule’s favor, not against it. That changes what a write + outside: work_folder rule actually does there, without changing how it matches. It still can’t inspect a path — the wire payload carries none, only thread/turn/item ids, a timestamp, and a reason — so it still matches every request the gate receives (see PolicyEvaluator._leaves_workspace, which treats an unresolvable path as outside by design). But because the gate now only receives requests for writes the sandbox has already refused, that blanket match is substantially correct on a sandboxed platform: action: deny denies approximately the out-of-workspace writes an operator means to stop, not ordinary writes the agent makes inside work_folder. It is coarse — no rule can allowlist one refused destination while denying another, since there’s nothing to distinguish them by — but it is not “the agent cannot write at all.” Two things complicate that picture, and both matter more than the mechanism above:
  • .git/, .agents/, and .codex/ are read-only carve-outs inside the project root. They’re excluded from the sandbox’s writable roots even though they sit inside work_folder, so a patch targeting any of them is refused and escalates exactly like one leaving the workspace entirely — and this rule denies it. Concretely: an agent installing a git hook, or writing to .agents/, is denied even though the target is “inside the workspace” in every everyday sense. Don’t describe this rule as never seeing an in-workspace write — that’s not accurate.
  • On a platform with no sandbox backend (Windows, or any OS upstream codex doesn’t implement one for) every patch escalates, regardless of location — so there, this rule really does deny every file change.
A separate, narrower point worth planning around: a write rule only covers codex’s patch tool. apply_patch is codex’s one structured file-edit mechanism, and the only thing that maps to the write effect. A shell command that edits a file — sed -i, a redirect, mv, anything run via the shell — surfaces as item/commandExecution/requestApproval instead, which maps to exec, not write (policy/effects.py), and the same split exists on the other two: claude_code’s Bash tool maps to exec, separately from Write/Edit/MultiEdit, and antigravity’s run_command maps to exec, separately from write_to_file and its siblings. What that means differs sharply by harness. On codex, an out-of-workspace shell write is still stopped — by the sandbox, which returns an error to the model directly rather than letting the write through. That refusal itself generates no write-specific approval request (this is exactly where shell and patch diverge: patch retries and escalates on a sandbox refusal, per the runtime-denial trigger above; shell does not) — the command as a whole can still surface its own item/commandExecution/requestApproval for reasons unrelated to where it writes, and an exec rule does see and match that. The write itself is not unconfined; what’s true is narrower: no policy rule is ever consulted about where the write lands, so an exec rule’s command globs govern which commands may run, not where a command’s writes end up. On claude_code and antigravity there is no sandbox at all (see “This is a guardrail, not a sandbox” below), so a shell-executed write has no backstop of any kind — not the sandbox codex has, and not a write rule, since neither Bash nor run_command maps to write. An exec rule is the only lever there is for a shell command on those two, and it can only match the command text, never its destination. The worker warns at activation (codex_write_confinement_path_blind, naming the rule) whenever a guarded codex thread’s policy carries a write + outside: work_folder rule, on every platform — read this section rather than either deleting the rule or trusting it to be the whole story. So what do you do about this?
  • Deleting the rule is not a fix — it is worse. With no write rule left to match, a codex file change falls through to the thread policy’s default instead — allow in every example on this page. Removing this rule because it “isn’t doing anything useful” restores no confinement and admits every out-of-workspace write it was denying, in the same move. Whatever this rule is currently doing to your codex thread, deleting it is not neutral.
  • The two mechanisms are complementary, not alternatives. Under both approval and guarded, the worker runs codex with sandbox_mode: "workspace-write" (codeagents/adapters/codex.py::_settings_kwargs), entirely independent of anything in policy.rules; only autonomous turns it off (sandbox_mode: "danger-full-access"). On codex, location confinement comes from that sandbox, rooted at work_folder — a write + outside: work_folder policy rule doesn’t add location confinement on top of it; it gates the escalations the sandbox already produces (deny them, or route them to a human with ask).
  • An exec rule does not add location confinement, on any of the three. On codex you don’t need it for shell writes — the sandbox already confines them, invisibly to policy. On claude_code and antigravity nothing can give you that guarantee for a shell-executed write; command globs are text matches, not a location check — write them to block commands you recognize as dangerous, not to confine where a command is allowed to write.

Antigravity: the gate is a hook in another process

The rules above are written the same way for every harness, but on antigravity the machinery answering them lives somewhere different, and that difference is visible to whoever owns the work folder. The Antigravity CLI has no in-process permission callback. Its only per-tool-call control point is a PreToolUse hook — a shell command named in a config file, which the CLI runs as a separate process and reads a verdict from. So for every guarded or approval thread the worker:
  1. opens a Unix socket for that turn and puts its path in the agent’s environment;
  2. writes <work_folder>/.agents/hooks.json declaring a hook that runs blobhub-worker agy-gate;
  3. lets the CLI spawn that command once per tool call. It relays the call over the socket, where the same permission gate every other harness uses answers allow or deny.
Three consequences follow, and each one changes something an operator can observe: Every tool call reaches the gate, not only escalations. The hook’s matcher is empty, which matches all of them. Under guarded that is exactly what the read effect is for. Under approval it means a human is asked about every call the agent makes, reads included — there is no harness-side auto-approval of read-only tools of the kind Claude Code performs before its callback is ever consulted. A denial reaches the model as text, verbatim. The reason travels back as tool call denied by pre-tool hook: <reason>, so the model reads Denied by policy rule 'confine_writes' or User denied in its own context and can explain or work around it. Rule ids are part of what the model sees — name them for a reader. The work folder carries a managed .agents/hooks.json while threads are running. It is written when the first thread using that folder starts and restored when the last one leaves — refcounted, because threads share a work folder by design, merged into an existing .agents/hooks.json rather than replacing it, and restored byte-for-byte (or deleted, if the worker created it) on release. Meanwhile it is a real file in the checkout and shows up in git status. See Filesystem Layout → The managed hook config for the full lifecycle and the failure mode it is shaped around. One more property, invisible until it matters: the worker never sends the CLI’s own ask verdict. A policy ask is resolved inside the worker — a human answers, and only the resulting allow or deny reaches the agent.

This is a guardrail, not a sandbox

Stated plainly: a policy stops mistakes, not a determined agent. command rules are string/glob matches against a command line — eval, a shell variable, or a script file defeats any pattern list just as easily as it would defeat a human skimming the same command. Real confinement is the operating system’s job, and of the three harnesses only codex has one underneath (its workspace-write sandbox); claude_code and antigravity have none, so on those two the policy genuinely is the only gate there is. The worker has exactly one workspace shape today — workspace.work_folder, a path on the worker’s own host — so the blast radius of a guarded run, reduced by whatever its rules happen to catch, remains the machine the worker runs on. Choose guarded because it removes a human from a loop your rules genuinely cover, not because it makes an agent safe to run unsupervised on a sensitive workspace.

Enforcement is audited, not assumed

A guarded turn posts a policy_applied item before it runs (the rule set in force, tagged by origin) and a policy_denied item for every call a rule refused — see Thread Items. policy_applied doubles as an enforcement acknowledgement: a guarded session with no policy_applied item was run by a worker version too old to enforce policy at all, and should not be trusted as gated.

Wire ↔ local mapping

The worker mirrors the envelope into a local thread.yaml on the worker host, but with a different field layout. The two must not be conflated: The local thread.yaml keeps the agent type under workspace.agent_type and the state under agent.state, and it additionally holds two local-only fields that never appear on the wire:
  • agent.agent_session_id — the SDK resume pointer used to reattach the agent after a restart.
  • agent.error — the {code, message} detail of a local failure.
Because agent_session_id and the error block are local-only, a reader inspecting the wire envelope will never see them there; thread failures surface to other clients as a thread_failed activity item, and the local detail is what the worker uses to recover. artifacts, attachments, and policy have no local counterpart at all — unlike agent.*, none of the three is ever mirrored into thread.yaml; all three are re-read from the envelope on every activation and recovery. See Filesystem Layout for the full local layout and Worker Session Object for the activity thread.

See also