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:
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 ofclaude_code,codex, orantigravity. 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, bothprefixandoutboxare 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. Unlikeartifacts, there’s no on/off switch here: the worker always resolves an inbox directory and always materializes anyfileblocks a thread item carries. An absent block, or aninboxthat 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 whenagent.permissionsresolves toguarded, where it becomes required: aguardedthread with nopolicyblock fails activation withPOLICY_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
pendingto hand the thread to the worker, - set
completedto stop the worker cleanly, - reset
failed → pendingto retry a failed thread.
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:- the thread’s own
agent.*on this envelope, - the worker’s
agents.<type>.*inconfig.yaml, - the codebase default.
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 optionalartifacts 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 carryfile 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:
- The worker’s own
policy.rules, set once inconfig.yaml(see Configuration → policy) — evaluated first, always. - This thread’s own
policyblock — evaluated second, and required for anyguardedrun: aguardedthread with nopolicyblock fails activation withPOLICY_MISSINGrather 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
Theeffect 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:
- 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. - Runtime denial.
apply_patchuniquely 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 insidework_folder, and isn’t.
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 insidework_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.
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
writerule left to match, a codex file change falls through to the thread policy’sdefaultinstead —allowin 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
approvalandguarded, the worker runs codex withsandbox_mode: "workspace-write"(codeagents/adapters/codex.py::_settings_kwargs), entirely independent of anything inpolicy.rules; onlyautonomousturns it off (sandbox_mode: "danger-full-access"). On codex, location confinement comes from that sandbox, rooted atwork_folder— awrite+outside: work_folderpolicy 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 withask). - An
execrule 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. Onclaude_codeandantigravitynothing can give you that guarantee for a shell-executed write;commandglobs 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 onantigravity 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:
- opens a Unix socket for that turn and puts its path in the agent’s environment;
- writes
<work_folder>/.agents/hooks.jsondeclaring a hook that runsblobhub-worker agy-gate; - 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
allowordeny.
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: apolicy 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
Aguarded 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 localthread.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.
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
- Handoff
- Thread Items
- Filesystem Layout
- Worker Session Object
- Configuration — the worker-side
policyceiling and rule reference upload_session_object

