Files
agent-runtime-framework/harnesses/contexts/shared-variables/v1/CLAUDE.md

7.1 KiB

Shared Variables Context

Your container is running with the shared variables filesystem mounted at /variables/. This is the only way you read a step's inputs and return its outputs to the control plane — there is no API to call, no credential to hold, no env var to substitute. You write plain files under your own step folder; the CP picks them up.

Normative source: spec/workflow-variables.md (WV-1..6a). This document summarises the contract; when in doubt, the spec wins.

What is where

/variables/
  workflow/                       # run inputs, frozen at dispatch time (WV-8)
    run-id
    template
    item.uuid
    item.title
    item.label
    item.flow-state
    artifacts.<key>               # per WorkflowInput.required_artifacts
  <step>/                         # this step's OWN folder — write outputs here
  <loop-id>_<n>/<step>/           # loop body node in iteration n (n is 1-based)
  .self                           # path to your own step folder (read-only)
  .done                           # completion marker, written by the entrypoint

You only write under your OWN folder. The pack sidecar walks that folder after you exit and returns its contents to the CP. Earlier loop iterations' results and your direct depends_on dependencies' outputs are also under /variables/, pre-filled when your container started — you only READ those.

Two env vars are set for you

  • WORKFLOW_VARIABLES_ROOT — always /variables.
  • WORKFLOW_VARIABLES_SELF — the path to your own step folder (e.g. /variables/judge for a top-level node named judge, or /variables/review-loop_2/judge for loop iteration 2's judge node).

Neither ever contains a value. Both are paths only. Treat both as read-only inputs.

Key naming (the contract you'll be checked against)

  • A variable's key is the joined, lowercased path from /variables/ to the leaf file, with . as the separator. /variables/judge/VERDICT ↔ judge.verdict. /variables/review-loop_2/judge/findings/HIGH ↔ review-loop_2.judge.findings.high.
  • Leaf files are UPPERCASE on disk by convention; folders stay lowercase. Lookup via the helpers and the CP API is case-insensitive.
  • Each path segment, after lowercasing, must match ^[a-z0-9][a-z0-9_-]{0,63}$. Maximum depth is 8 segments. Dotfiles, symlinks, FIFOs, sockets, and devices are skipped with a warning (never read or followed).
  • Two leaf files at the same level whose names lowercase to the same key → both rejected at pack time as invalid_output_key.
  • Reserved names that cannot be used as step or loop ids (case-insensitive): workflow, while, for, foreach, loop, until, do, repeat, if, else, elif, elseif, then, switch, case, default, when, break, continue, return, goto, yield, try, catch, except, finally, raise, throw, and, or, not, xor, in, is, true, false, null, none, nil, yes, no, on, off. The full list lives in controlplane/typed_workflows/reserved_ids.py and may grow by spec amendment.
  • Step/loop ids are restricted to lowercase letters, digits, - and _, starting with a letter; no trailing _<digits> (reserved for loop-iteration suffixes).

What you can see (visibility)

  • workflow.* — always. These are the run inputs, frozen for the life of the run (later item changes are not reflected here; pass them on through step outputs).
  • The outputs of each of your direct depends_on nodes (the same set the manifest DAG wires you to) — not transitive.
  • Inside a loop body, every prior iteration's outputs for every step in the loop — not just the immediately preceding one. So iteration 2 sees what iterations 1, 2, … of every loop body node wrote. Iteration 1 sees only its direct dependencies.
  • Nothing else. Your own folder is always created, even if empty.

Declared outputs

If your node's template declares outputs: [<key>, ...], those are the keys you are expected to write. Any file actually present under your step folder is accepted regardless — outputs: is for policy and tooling, not an allowlist. A missing declared output produces a warning by default (configurable per-project to fail the step with variables_missing_output).

Values

  • Strings only. JSON is supported as a string value — you parse it yourself; the CP never interprets a value's structure.
  • Per-value cap is configurable (max_value_bytes, default 1 MiB). Per-step cap (max_step_bytes) and per-step file count (max_step_files) also apply.
  • Values are data — never substituted into prompts, commands, or environment variables by the CP. Don't put values in shell; read them through the helpers or the CLI.

Helpers (use these, not shell)

A Python package is pre-installed in your image at /opt/agent/variables/ and a CLI at /usr/local/bin/wfvar. Both are stdlib-only.

from variables import load, save, keys

value = load("verdict")                    # load from your own folder
save("verdict", "CLEAN")                   # write into your own folder
keys()                                     # list keys under your own folder
keys(prefix="judge.")                      # narrow by prefix (read-only access)
wfvar get VERDICT                 # print the value, NO trailing newline
wfvar set VERDICT CLEAN           # write into your own folder
wfvar list                        # list your own keys
wfvar path                        # print your own step folder (same as $WORKFLOW_VARIABLES_SELF)
wfvar get judge.findings.high     # read another step's output (visibility permitting)

Exit codes: 0 ok, 1 not found (key is valid but absent), 2 invalid key or usage, 3 value exceeds per-value limit, 4 write attempted outside your own step. Do not wrap wfvar get with extra shell that strips newlines — it prints exactly the stored bytes.

Don't write plan-like content with wfvar set <key> <body> — argv breaks on shell quoting and truncates at ARG_MAX. For multi-line / quoting-sensitive bodies (e.g. the decompose plan files at /variables/<node>/items/<local_id>), use your file-write tool or a heredoc with a terminator that cannot occur in the body, writing directly into /variables/<node>/....

Safety

The pack sidecar reads your step folder with strict traversal protections (O_NOFOLLOW, S_ISREG checks, size caps, 4 KiB marker cap). It does not follow symlinks you might drop in. A symlink at any level is silently skipped with a warning — never assume it will be read.

What you must NOT do

  • Do not read or write outside your own step folder under /variables/.
  • Do not call out to the CP API directly — the helpers and the pack sidecar handle all variable transport.
  • Do not assume /variables/ exists in an unstamped container — it doesn't.
  • Do not write to /variables/.done — the entrypoint owns that marker; it is written for you after run_finalize_phase returns, whether you exited 0, non-zero, or via SIGTERM.
  • Do not store values in env vars, command lines, or prompts — values are data, and data doesn't go there.