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_folderrequired. An absolute path to the repository on the worker host.
  • agent.typerequired. One of claude_code or codex.
  • Everything else under agent.*optional. Omitted fields fall through the settings tiers below.
  • artifactsoptional. Absent means the run publishes nothing. When present, both prefix and source 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.
  • policyoptional, 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 and codex for codex. 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 one session object per file, under an alias prefix you choose.
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 source recursively (files only, symlinks not followed, its own outbox .gitignore excluded, sorted for determinism); each file’s path relative to source becomes the alias suffix, so docs/plan.md publishes to <prefix>docs/plan.md. A file is skipped, not failed, when it is not valid UTF-8 (binary), exceeds the object size budget (too_large5 MiB minus 64 KiB of envelope headroom), the constructed alias is invalid (invalid_alias), it can’t be opened (unreadable), 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 source fails the activation-time check instead, no item is posted on any turn of that activation — see the source row above and Thread Items for both of source’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.

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 two 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. 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 claude_code: its Bash tool maps to exec, separately from Write/Edit/MultiEdit. 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 there is no sandbox at all (see “This is a guardrail, not a sandbox” below), so a Bash-executed write has no backstop of any kind — not the sandbox codex has, and not a write rule, since Bash never maps to write. An exec rule is the only lever there is for a shell command on claude_code, 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 either harness. On codex you don’t need it for shell writes — the sandbox already confines them, invisibly to policy. On claude_code 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.

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 two harnesses only codex has one underneath (its workspace-write sandbox); claude_code has none, so on claude_code 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 and policy have no local counterpart at all — unlike agent.*, neither is ever mirrored into thread.yaml; both 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