# YAML Spec Reference (v3)

Container + session knobs nest under the engine that interprets them
(`spec.apptainer.*`, `spec.claude.*`). Cross-cutting knobs (workdir,
a2a, health, restart, autonomous, listen, skills, telegram, hooks)
stay at the top level. Every curated block has a `raw_*` escape hatch
— the full underlying surface is always reachable.

> **On the name `spec.claude`.** It is a legacy key name, not a scope
> claim: the block carries the SDK-harness session/model knobs
> generally, and its `provider` sub-key demonstrably configures
> non-Anthropic endpoints. Do not read `spec.claude.*` as "the Claude
> harness only" — and do not confuse `spec.claude.provider` (which
> *inference endpoint* answers) with the top-level `spec.harness`
> (which *agent program* drives the turn). The two axes compose.

The agent name is the parent directory of `spec.yaml` (dir-as-SSoT —
no `metadata.name` field).

## Quick links

- Annotated full example: [`examples/agents/full-agent/spec.yaml`](https://github.com/scitex-ai/scitex-agent-container/blob/develop/examples/agents/full-agent/spec.yaml) — every supported field with inline comments
- Minimal example: [`examples/agents/minimal-agent/spec.yaml`](https://github.com/scitex-ai/scitex-agent-container/blob/develop/examples/agents/minimal-agent/spec.yaml)
- Quickstart with `startup_prompts`: [`examples/agents/hello-agent/spec.yaml`](https://github.com/scitex-ai/scitex-agent-container/blob/develop/examples/agents/hello-agent/spec.yaml)

## Top-level shape

```yaml
apiVersion: scitex-agent-container/v3    # REQUIRED — v1/v2 raise loud validation errors
kind: Agent                              # REQUIRED — Agent | AgentProxy
                                         # (AgentProxy → HTTP forwarder, no SDK;
                                         #  see spec.proxy + examples/agents/proxy-agent)

metadata:
  labels:                                # drives `sac fleet` filters AND the AgentCard
    role: ecosystem-auditor
    team: lab-a
    description: ...                     # → AgentCard.description
    function: audit, git status, ...     # → AgentCard.skills[0].description
    capabilities: audit,health-check     # CSV → AgentCard.skills[0].tags
    cardinality: singleton               # → AgentCard.x-scitex-agent-container.cardinality

spec:
  runtime: apptainer                     # optional; only `apptainer` accepted; empty defaults to `apptainer` (since 2026-05-13)
  workdir: ~/proj                        # mounted rw at /work
  to_home: ./to_home                     # mirrored into the agent $HOME at start (auto-discovers ./to_home; default "./to_home")
  python-venv: auto                      # string or list — fallback chain
  env-file: .env                         # string or list of dotenv paths   (VERIFY: validator currently rejects — must be added to _KNOWN_SPEC_KEYS)
  multiplexer: tmux                      # tmux | screen                    (VERIFY: validator currently rejects — must be added to _KNOWN_SPEC_KEYS)

  apptainer:    { ... }
  claude:       { ... }
  mcp_servers:  { ... }
  health:       { ... }
  restart:      { ... }
  autonomous:   { ... }
  a2a:          { host: 127.0.0.1, port: auto }    # port: auto | <int> | null (disable)
  proxy:        { upstream: https://peer/, trust: untrusted }   # kind: AgentProxy only
  listen:                                # LIST of side-port DECLARATIONS (no binding):
    - { port: 9000, proto: tcp, name: api, owner: app }
    - { proto: unix, path: /tmp/x.sock, name: ipc }
  # NOTE: the host-level `sac listen` server port lives in
  # ~/.scitex/agent-container/config.yaml (listen.port, default 7878),
  # NOT in agent spec.yaml.
  startup:                               # (optional) ready-pattern gating block (todo#291)
    commands: [...]                      # shadows top-level startup_commands when set
    ready_patterns: [...]                # regex strings (or { regex: "..." } dicts)
    ready_idle_ticks: 3
    ready_poll_interval_seconds: 0.5
    ready_timeout_seconds: 60
    on_timeout: capture_and_proceed      # capture_and_proceed | capture_and_fail
  available_harnesses:
    hermes:
      session: { mode: continue, max_age_minutes: 4320 }
      background_review: false
      # run_budget_seconds: 900  # optional; one-shot/eval jobs only
      compression:
        threshold: 0.80
        threshold_tokens: null           # optional absolute trigger cap
        target_ratio: 0.20
        tail_mode: lean
        in_place: true
  comms:
    channels: [server:sac, server:scitex-cards]
    outbound: { siblings: allow, parent: allow }
    inbound: { siblings: allow, parent: allow }
    a2a: { listen: true }
  telegram:     { bot_token_env: ..., allowed_users: [...], auto_connect: true, greeting: ... }
  hooks:        { pre_start: [...], post_start: [...], pre_stop: [...], post_stop: [...] }
  extensions:   { ... }                  # opaque per-deployment dict

  startup_commands:                      # SHELL before claude starts (list of {delay, command} dicts)
    - { delay: 0, command: "echo hi" }
  startup_prompts:  [...]                # TEXT fed to the selected harness as first user msg
  session: continue                      # top-level shortcut overriding spec.claude.session

  host:  gpu-box                         # mutually exclusive: singleton on one peer
  hosts: [laptop, gpu-box, nas]          # OR multi-instance, one per peer
```

## Field reference

### `metadata.labels` → AgentCard fields

> **Note on naming — two "skills" concepts.** A2A's AgentCard has a
> standard top-level `skills[]` array used to *advertise* capabilities
> to peers (id / name / description / tags / examples). Anthropic's
> Claude Code separately uses "skills" for *prompt-fragment* markdown
> files under `<HOME>/.claude/skills/<name>/` that the SDK loads into
> the agent's own context. Both share the English word but live at
> orthogonal layers:
>
> | Layer | Drives | Effect |
> |---|---|---|
> | `metadata.labels.skills` (CSV) | A2A `skills[0].tags` + `x-scitex-agent-container.required_skills` | Advertises capabilities on the card; **no behaviour change inside the agent** |
> | `spec.to_home/.claude/skills/<name>/SKILL.md` (files) | Materialised at `runtime/<name>/home/.claude/skills/` (ADR-0006) and surfaced via `spec.skills.required[]` `@`-imports in the auto-generated CLAUDE.md | Loaded into the agent's prompt by the Claude SDK |
>
> Also note A2A's separate top-level `capabilities` field is for
> *transport* properties (`streaming`, `pushNotifications`, etc.) —
> not a synonym for "what the agent can do". The "can do" surface is
> always `skills[]`.

The AgentCard at `GET /.well-known/agent-card.json` (per-agent sidecar
when `spec.a2a.port` is set) and `GET /agents/<name>/card`
(host-level `sac listen`) is built **entirely** from spec.yaml:

| AgentCard field                       | spec.yaml source                                 |
|---------------------------------------|--------------------------------------------------|
| `name`                                | parent directory of `spec.yaml`                  |
| `description`                         | `metadata.labels.description` (else auto)        |
| `version`                             | `apiVersion`                                     |
| `url`                                 | `<base>/agents/<name>`                        |
| `provider.organization`               | `metadata.labels.team`                           |
| `skills[0].id` / `name`               | `metadata.labels.role`                           |
| `skills[0].description`               | `metadata.labels.function`                       |
| `skills[0].tags`                      | `metadata.labels.capabilities` ∪ `metadata.labels.skills` (both CSV) |
| `x-scitex-agent-container.role_class` | `metadata.labels.role`                           |
| `x-scitex-agent-container.cardinality`| `metadata.labels.cardinality`                    |
| `x-scitex-agent-container.scheduling` | derived from `spec.host` / `spec.hosts`          |
| `x-scitex-agent-container.runtime`    | `spec.runtime`                                   |
| `x-scitex-agent-container.model`      | `spec.claude.model` (v3) / `spec.model` (v2 back-compat) |
| `x-scitex-agent-container.required_skills` | `metadata.labels.skills` (CSV) ∪ legacy `spec.skills.required` |
| `x-scitex-agent-container.multiplexer`| `spec.multiplexer`                               |

### `spec` — top-level

| Field                | Type                       | Description                                                              |
|----------------------|----------------------------|--------------------------------------------------------------------------|
| `harness`            | `anthropic` (default) \| `openai` \| `codex` | **Which agent program drives the turn** — NOT `spec.claude.provider`, which points the SDK session at an Anthropic-compatible *inference* endpoint. The two axes compose, and `codex` is a legal value of BOTH: as a HARNESS the Codex agent program runs the loop, as a PROVIDER Claude Code still drives and Codex only answers. **Launch caveat today — read before choosing:** the registry has four entries (`claude-code-tui`, `claude-agent-sdk`, `openai-agents`, `codex-sdk`), but only `anthropic` can be STARTED. `openai` and `codex` load, validate and resolve to their registry entries, then every lifecycle launch path REFUSES them — loudly, rather than silently launching a Claude runner under a spec that asked for something else. The working path for the OpenAI SDK meanwhile is the `openai_session` A2A executor (`spec.a2a.handler`); there is no equivalent executor for `codex` yet. |
| `provider`           | *(deprecated alias of `harness`)* | The spelling this field had before it was renamed. Still honoured: a spec carrying `provider:` loads unchanged; starting it logs a one-line deprecation. Both keys with the SAME value is fine; both with DIFFERENT values is a hard load error naming both. |
| `runtime`            | `tui` (default) \| `claude-agent-sdk` \| `apptainer` | **Launch mode within the Anthropic harness family.** Empty/unset = `tui` (the interactive Claude Code TUI in a PTY); `claude-agent-sdk` = the headless SDK runner; `apptainer` is the back-compat spelling of the pre-2026-06-13 container-engine field and maps to `claude-agent-sdk`. docker/podman were dropped 2026-05-13. |
| `workdir`            | path                       | Mounted rw at `/work` (default: `~/.scitex/agent-container/runtime/agents/<name>/`) |
| `to_home`            | path                       | Mirrored into the agent's container `$HOME` (= `runtime/<name>/home/`) at start. Every path under `to_home/` lands at the same relative path under `$HOME`. Default `./to_home` — auto-discovers a sibling `to_home/` next to `spec.yaml`. |
| `python-venv`        | string \| list             | Pre-activated for startup_commands; `auto` probes `~/.venv-3.11`, `~/.venv` |
| `env-file`           | string \| list             | dotenv paths sourced at start. **(VERIFY: parsed by the loader but currently rejected by `_validation._KNOWN_SPEC_KEYS` — known parser/validator drift.)** |
| `user`               | `""` \| `"host"` \| `"<uid>:<gid>"` | Container user override; empty = image default. |
| `multiplexer`        | `tmux` \| `screen`         | Long-lived session host (default `tmux`). **(VERIFY: parsed by the loader but currently rejected by `_validation._KNOWN_SPEC_KEYS` — known parser/validator drift.)** |
| `host` / `hosts`     | string / list of strings   | Singleton on one peer / multi-instance one-per-peer (mutually exclusive). `hosts: "all"` = every fleet host. |
| `session`            | string                     | Top-level shortcut overriding `spec.claude.session`; legacy aliases accepted (`continue-or-new`, `new`). |
| `screen.name`        | string                     | Legacy metadata (agent display name in `sac fleet`). Default = agent name. Does NOT drive a multiplexer. |
| `startup_commands[]` | list of `{delay, command}` | Run **before** the harness process starts. Each item is a dict with optional `delay` (int seconds, default 0) and required `command` (string); bare strings are not accepted. |
| `startup_prompts[]`  | list of strings            | Required explicitly; `[]` means no startup turn; no implicit prompt fallback |

### `spec.apptainer` — engine knobs

| Field         | Type                          | Description                                                |
|---------------|-------------------------------|------------------------------------------------------------|
| `image`       | path to `.sif`                | `sac-scitex.sif` (full stack) or `sac-base.sif` (minimal). Optional; empty falls back to the sac default SIF at dispatch. |
| `overlay`     | path                          | Writable rw layer above the SIF                            |
| `overlay_size` | size string (e.g. `"5G"`, `"500M"`) | When set together with `overlay`, sac auto-creates the overlay image at that path with the given size if it doesn't exist (declarative — no manual `apptainer overlay create` step). Units: M/MB/G/GB only (K/KB rejected). Empty = no auto-create (missing overlay raises a clear FileNotFoundError at launch). |
| `overlay_create_if_missing` | bool (default `true`) | Gate for the auto-create behaviour above. When `false` AND the overlay is missing, sac raises FileNotFoundError without attempting creation (operator must pre-create with `apptainer overlay create`). |
| `binds[]`     | `host:container[:ro\|rw]` (or legacy `{src,dst,mode}` dict) | Bind mounts. Source side supports `~` / `$VAR` (sac expands before calling apptainer). Destination MUST be absolute (apptainer rejects relative / `~` / `$VAR`); conventional roots are `/home/agent/...` (D5 canonical HOME), `/srv/`, `/work/`, `/opt/`, `/data/`. The legacy `{src, dst, mode}` dict form is still accepted by the parser and normalized to the string form. |
| `env`         | key-value dict                | Env vars exported into the container                       |
| `container_workdir` | path (default `/work`)  | Working directory inside the container.                    |
| `nv` / `rocm` | bool                          | Forward host NVIDIA / AMD ROCm libs. (DESIGN — mutual exclusion not currently enforced by the parser.) |
| `raw_args[]`  | list of strings               | **Escape hatch** — appended verbatim to `apptainer exec`   |
| `post` / `environment` / `def_file` | string / KV dict / path | Apptainer `%post` shell snippet, `%environment` KV map, and override `.def` path for `apptainer build`. Empty / missing → no build extension. |
| `relaxed`     | bool (default `false`)        | **(DESIGN — not yet implemented in the parser.)** Intent: opt OUT of hardened-by-default isolation. When `false` (default), sac auto-prepends `--containall` / `--cleanenv` / `--writable-tmpfs` / `--home /home/agent`. Set `true` to disable; see [`docs/isolation.md`](isolation.md) + [`docs/adr/0001-isolation-hardening.md`](adr/0001-isolation-hardening.md). TODO: wire into `ApptainerSpec`. |
| `fakeroot`    | bool (default `false`)        | **(DESIGN — not yet implemented in the parser.)** Intent: apptainer `--fakeroot` — uid 0 inside via user-namespace remap; host uid unchanged. D5 preflight detects userns-fakeroot via `/proc/self/uid_map` and accepts uid 0 only when remapped. TODO: wire into `ApptainerSpec`. |

### `spec.available_harnesses.claude-code.account`

`account` pins a Claude Code harness to one saved Anthropic OAuth account by
its `sac account list` slug. It belongs to the harness that consumes the
credential, not to the legacy `spec.claude` compatibility block:

```yaml
spec:
  harness: claude-code
  available_harnesses:
    claude-code:
      account: scitex-01-scitex-ai
      session: {mode: continue, max_age_minutes: null}
      approval_policy: never
      watchdog:
        enabled: false
        interval: 1.5
        responses: {y_n: "1", y_y_n: "2", waiting: /speak-and-call}
```

The slug must be a non-empty string with no surrounding whitespace. At the
parser boundary SAC folds it into the typed Claude runtime configuration, so
the existing start preflight checks that exact stored-account preference and
the auth bind resolves its snapshot. `account` is rejected on other harness
entries because Codex and Hermes do not consume Claude Code OAuth snapshots.

### `spec.available_harnesses.<key>.compression` — Hermes only

Hermes context compaction is configured beside the Hermes harness that owns
the behavior. The block is optional; omitting it preserves SAC's current
Hermes defaults: `threshold: 0.80`, `threshold_tokens: null`,
`target_ratio: 0.20`, `tail_mode: lean`, and `in_place: true`.
`threshold_tokens`, when set, is a positive integer absolute cap: Hermes
compacts at the lower of the ratio-derived trigger and this value while the
model's truthful `context_length` remains unchanged. The ratios must satisfy
`0 < target_ratio < threshold < 1`; `tail_mode` is `lean` or `legacy`, and
`in_place` is a boolean.

For agents sharing one engine, derive the absolute trigger from fresh engine
KV capacity rather than adding their independent model windows. SAC's
fail-closed helper reserves 25%, divides the remainder by the sharing agents,
and rounds down to a binary token boundary. Six agents sharing an observed
`max_total_num_tokens: 2180096` therefore use `threshold_tokens: 262144`:
their six triggers total 1,572,864 tokens and leave 607,232 tokens of reserve.
A missing or stale capacity observation is an error. A 524,288-token trigger
is suitable only for a controlled single-agent canary; six such triggers total
3,145,728 and are not a safe fleet steady state.
The derivation helper is intentionally pure and does not rewrite running
agents. An operator or deployment planner must put its `threshold_tokens`
result in each selected Hermes harness block; normal spec loading then carries
that value into the compiled Hermes profile on the next authorized restart.
The same block under Claude Code, Codex, or another harness is rejected rather
than silently ignored. The former top-level `spec.context_management` example
was removed because that tolerated legacy key has no runtime consumer.

### `spec.available_harnesses.hermes.background_review`

This boolean controls Hermes' automatic post-turn review. It defaults to
`false`, so SAC-managed Hermes agents do not silently issue a second request
over the full conversation after completing foreground work. Set it to `true`
only when that extra review traffic is intentional. SAC compiles the value to
Hermes' internal `auxiliary.background_review.enabled` setting.

On 2026-09-12, aligned Hermes and inference-server logs showed an automatic
review replaying about 691,000 tokens while the next foreground turn began on
the same conversation. After the gateway restarted and cancelled the review,
the foreground turn performed a cold prefill and took about 276 seconds. This
field makes that high-cost behavior declared and testable rather than implicit.

### `spec.available_harnesses.hermes.run_budget_seconds`

This optional positive integer gives one Hermes run a wall-clock checkpoint for
one-shot/eval jobs that already have an external deadline. It is unset by
default for agentic SAC sessions, so Hermes runs to completion and does not
receive the 80%-elapsed wrap-up instruction. When explicitly set, SAC compiles
the value to Hermes' internal `agent.run_budget_seconds` setting. This is not a
hard kill and does not trigger context compression; it injects a one-time
wrap-up notice and tightens implicit stale-call timeouts.

SAC separately emits Hermes' `agent.max_turns: none` sentinel for agentic
sessions. This keeps Hermes' own tool loop unbounded while leaving
`spec.autonomous.max_turns` as the independent safety cap for SAC's outer
drive-until loop. One-shot/eval callers can still use Hermes CLI overrides.

Telegram is an edge transport, not a per-agent dependency. Select the CCT
Telegram channel only on the human-facing gateway agent (normally
`scitex-lead`); application agents communicate through Cards, SAC, or A2A.
For each spec that explicitly selects CCT, SAC owns one poller using that
spec's declared token. SAC neither creates nor requires a separate BotFather
token for every agent. Selecting the same Telegram bot in several agent specs
would create competing pollers and is not a supported topology.

A CCT turn is acknowledged only after Hermes proves the exact delivery marker
visible in its native session. The receipt identifies `agent`, `delivery_id`,
and `exchange_id`, so a gateway or web client can report and retry the turn
without an operator watching or attaching to the TUI.

### `spec.claude` — SDK knobs

| Field                       | Type                                  | Description                                                       |
|-----------------------------|---------------------------------------|-------------------------------------------------------------------|
| `model`                     | alias or full ID (default `sonnet`)   | The model this agent's turns run on. Without a `provider` override it is a Claude model — see **Available models** below. WITH one it is the override endpoint's own id (e.g. `deepseek-chat`, `gpt-5.6-sol`) and the `claude-*` alias check relaxes. **The `sonnet` default is applied to every agent regardless of harness** — an Anthropic-shaped default the harness/runtime/inference layering work has not yet unwound. |
| `account`                   | string                                | Pin this agent to a stored OAuth account (`sac accounts` store-name). Mutually exclusive with `provider`. |
| `provider`                  | `{ base_url, auth_token_env }`        | Point the SDK session at any Anthropic-compatible endpoint (e.g. DeepSeek). `base_url` is the endpoint; `auth_token_env` is the NAME of the host env var holding the key (never the key). Mutually exclusive with `account`; relaxes the `claude-*` model-alias check. See ADR-0011. |
| `session`                   | `continue` \| `new-session` \| `resume`| Session strategy (default `continue` — safe fallback). Legacy aliases `continue-or-new`, `new` accepted |
| `resume_id`                 | string                                | Explicit session UUID for `session: resume`                       |
| `continue_max_age_minutes`  | int                                   | Only resume if the stored session is newer than N minutes. Hermes defaults to and caps this at 4320 (3 days). |
| `flags[]`                   | list of strings                       | Extra flags appended to `claude` invocation                       |
| `auto_accept`               | bool (default `True`)                 | Auto-confirm permission prompts in the TUI                        |
| `raw_options`               | dict                                  | **Escape hatch** — splatted into `ClaudeAgentOptions(**raw_options)` |

### `spec.comms` — harness-neutral communication

`spec.comms.channels` declares inbound communication adapters once for the
agent. The selected harness does not own this list. SAC adapts the same
declaration to Claude Code, Hermes, or Codex while preserving the same durable
SAC/Cards identity and exchange ledger.

```yaml
spec:
  comms:
    channels:
      - server:sac
      - server:scitex-cards
    outbound: { siblings: allow, parent: allow }
    inbound: { siblings: allow, parent: allow }
    a2a: { listen: true }
```

With `available_harnesses`, a `channels` key inside a harness entry is rejected
with a relocation hint. Older direct `spec.claude.channels` specs remain
readable only for the fleet migration window; new specs must use
`spec.comms.channels`.

#### Available models (`spec.claude.model`)

sac validates `model` at YAML-load time, then hands it to the Claude Code
SDK (`claude --model`), which resolves the value to a concrete model. Two
shapes are accepted:

**1. Bare alias** — recommended; auto-tracks the latest version of that family:

| Alias               | Resolves to (current, 2026-05)        | When to use                         |
|---------------------|---------------------------------------|-------------------------------------|
| `opus`              | Claude Opus 4.7                        | Most capable; heaviest / slowest    |
| `sonnet`            | Claude Sonnet 4.6 — **default**        | Balanced capability and speed       |
| `haiku`             | Claude Haiku 4.5                       | Fastest, cheapest; light tasks      |
| `inherit` / `default` | SDK / host default                  | Don't pin a family explicitly       |

Append `[1m]` for the 1M-token context window where the model offers it —
e.g. `opus[1m]`, `sonnet[1m]`.

**2. Full versioned ID** — pins one exact model, no auto-tracking:
`claude-<family>-N-M[-<date>][[ctx]]`, e.g. `claude-opus-4-7`,
`claude-opus-4-7[1m]`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001`.

Abbreviated forms missing the version digits (e.g. `claude-opus[1m]`) are
**rejected at validate-time** — they pass the YAML loader but the SDK
silently returns zero tokens (no API call), so the agent looks hung. The
pinned regex catches this early.

> Under a `provider` override (e.g. DeepSeek) the `claude-*` alias check
> is relaxed — use the provider's own model names instead.

### `spec.health` / `spec.restart` / `spec.watchdog` / `spec.autonomous`

| Field                       | Description                                                                              |
|-----------------------------|------------------------------------------------------------------------------------------|
| `health.enabled`            | bool — enable periodic liveness probe                                                    |
| `health.interval`           | seconds between probes                                                                   |
| `health.timeout`            | per-probe timeout                                                                        |
| `health.method`             | `sdk-alive` (only value accepted by the validator). NOTE: the parser default is the legacy string `multiplexer-alive`; with the validator pin in place, any explicit value other than `sdk-alive` is rejected at load time. |
| `autonomous.idle_kick_after_s` | int seconds — minimum nudge cadence when no tool activity (default 120). A Hermes TUI applies its 60-second anti-busy-loop floor, then adds a deterministic per-agent 0–59 second cadence offset. |
| `restart.policy`            | `never` \| `on-failure` \| `always`                                                      |
| `restart.max_retries`       | int                                                                                      |
| `restart.backoff.initial`   | seconds before first retry                                                               |
| `restart.backoff.max`       | cap on backoff                                                                           |
| `restart.backoff.multiplier`| exponential factor                                                                       |
| `watchdog.enabled`          | parsed for back-compat; lifecycle managed via hooks                                      |
| `autonomous.enabled`        | drive turns until `drive_until` token or `max_turns`                                     |
| `autonomous.drive_until`    | string token the agent prints when done (default `DONE`)                                 |
| `autonomous.max_turns`      | int                                                                                      |
| `autonomous.kick_text`      | nudge sent when the agent pauses                                                         |

For an owning Hermes TUI, enabling this block arms Hermes' native session
heartbeat at launch. The heartbeat fires only while the run is idle and the
native input queue is empty, so queued human guidance/steering takes priority.
Each wake asks the agent to re-read Cards, verify assignment and ownership,
and reject overlap before editing. SAC does not poll Cards or issue model
turns on a timer itself. Hermes cron remains the right mechanism for a
wall-clock job; the session heartbeat is the right mechanism for an idle
worker that should look for another durable Card.
When an external CI/review status is merely pending, the wake contract records
that evidence and returns idle for a later heartbeat rather than holding an
active turn open with polling sleeps. A real running test, build, or useful
process is not a polling sleep and may still be monitored normally.
The exact Hermes stagger interprets the first eight SHA-256 digest bytes of the
agent name as an unsigned big-endian integer, takes
`integer mod min(60, configured_interval)` seconds, and adds that to the
configured/floored interval. It is stable across restarts and hosts, and the
configured value remains the minimum idle backoff.

### `spec.a2a` / `spec.listen` — network endpoints

| Field        | Description                                                                          |
|--------------|--------------------------------------------------------------------------------------|
| `a2a.host`   | Bind interface for the per-agent A2A sidecar (default `127.0.0.1`).                  |
| `a2a.port`   | `auto` (default) — sac claims a free port from `~/.scitex/agent-container/config.yaml`'s `a2a.port_range` (default 19000-19999), persists it in the shared PostgreSQL store, and surfaces it via `sac agents list`. Set an explicit int (e.g. `7901`) to pin for a stable external URL. Set `null` to disable the sidecar entirely. **Most operators never touch this** — auto is the right default. |
| `listen[]`   | LIST of side-port DECLARATIONS (NOT a single port override). Each item: `{port, proto, path, name, owner}`. `proto`: `tcp` (default) / `udp` / `unix`. Entries that fail validation (`tcp`/`udp` needs `port>0`; `unix` needs `path`) are silently dropped. **The container does NOT bind these — declarations only**, surfaced on the AgentCard for peers. The host-level `sac listen` server port (default 7878) is configured in `~/.scitex/agent-container/config.yaml` under `listen.port`, NOT here. |

The per-agent sidecar binds the **same URL shape** as `sac listen`
(`/agents/<name>/{turn,send,card}`, `/v1/a2a/agents/<name>/...`,
`/.well-known/agent-card.json`, `/health`), so the same client code
works against either transport. Per-agent ports are an internal IPC
mechanism between `sac listen` and the runner (different processes);
clients reach every agent through the **one stable host port** at
`sac listen` (default `:7878`).

The AgentCard's `url` field advertises the **sac listen** URL
(`http://127.0.0.1:7878/agents/<name>`) regardless of which
endpoint served the card, so external A2A clients caching the card
get a URL that survives per-agent port churn.

### `~/.scitex/agent-container/config.yaml`

Host-wide sac configuration. All keys optional; defaults shown.

```yaml
listen:
  host: 127.0.0.1        # bind interface for sac listen (loopback only)
  port: 7878             # host control-plane port

a2a:
  port_range: [19000, 19999]   # range the auto-allocator picks from
```

### Skills

`spec.skills` was **removed in v3** — skills now live under
`to_home/.claude/skills/` (a sibling directory next to `spec.yaml`,
materialized into the agent's `$HOME` at start).

For AgentCard publication, declare the skill IDs via
`metadata.labels.skills` as a CSV (e.g. `skills: "scitex-dev, gh-cli, git"`).
The list ends up in the card's `skills[0].tags` (unioned with
`metadata.labels.capabilities`) and `x-scitex-agent-container.required_skills`.

### `spec.mcp_servers`

A dict-of-dicts merged into `<workdir>/.mcp.json` at start. Mirrors
the `.mcp.json` shape directly. Use this OR drop a `.mcp.json` into
`to_home/` (lands at `$HOME/.mcp.json`).

### `spec.telegram` / `spec.hooks` / `spec.extensions`

| Field                  | Description                                                                |
|------------------------|----------------------------------------------------------------------------|
| `telegram.bot_token_env`| Env var name holding the bot token (default `SCITEX_AGENT_CONTAINER_TELEGRAM_BOT_TOKEN`) |
| `telegram.allowed_users`| List of Telegram user IDs (strings) allowed to talk to this bridge        |
| `telegram.auto_connect`| bool (default `true`) — auto-attach the bridge at agent start              |
| `telegram.greeting`    | Optional greeting string posted on connect                                 |
| `hooks.pre_start[]`    | Shell commands before `apptainer exec` (a `mkdir -p <workdir>/.claude` is auto-prepended) |
| `hooks.post_start[]`   | Shell commands after the runner reports ready                              |
| `hooks.pre_stop[]`     | Shell commands before SIGTERM                                              |
| `hooks.post_stop[]`    | Shell commands after the runner exits                                      |
| `extensions`           | Opaque dict — read by downstream tooling (priority, owner, etc.)           |

## Lifetime / session selection

Default = long-lived + safe-fallback session continue. The `sac
agents start` CLI overrides at start time:

```bash
sac agents start <name> --one-shot                 # exits after first startup_prompt
sac agents start <name> --session continue         # default (try continue, fall back to fresh)
sac agents start <name> --session new-session      # force fresh
sac agents start <name> --resume <sid>             # implies --session resume
```

CLI flags ALWAYS override the YAML — one-direction precedence so a
per-invocation tweak doesn't mutate the persistent default.

## `kind: AgentProxy` — HTTP forwarder agents

A proxy agent forwards `POST /v1/turn` to an **external A2A
endpoint** instead of running an agent session in-process.
There is no SDK in the container; the runner is a thin Starlette
forwarder (image: `sac-proxy.sif`, lighter than `sac-scitex.sif` —
no Python ML stack).

Authoring contract:

- `kind: AgentProxy` (instead of `kind: Agent`).
- `spec.proxy` is **REQUIRED**.
- `spec.claude`, `spec.startup_prompts`, `spec.startup_commands` are
  rejected at validation time (no SDK to configure / prompt).
- `spec.a2a.port` works the same — that's the port operators POST to.

### `spec.proxy` reference

| Field           | Type              | Default       | Notes                                                                                  |
|-----------------|-------------------|---------------|----------------------------------------------------------------------------------------|
| `upstream`      | string (REQUIRED) | —             | Full URL to the upstream A2A endpoint (must start with `http://` or `https://`).        |
| `trust`         | enum              | `untrusted`   | `untrusted` / `local-mesh` / `trusted`. Advisory — surfaced on our AgentCard.           |
| `redact`        | list[str]         | `[]`          | Substring tokens; any inbound `text` containing one is refused HTTP 400 before forward. |
| `timeout_s`     | float > 0         | `30.0`        | Per-turn upstream HTTP timeout. Longer forwards return HTTP 504 to the caller.          |

### Security notes

- Proxy is HTTP-only — no mTLS in the MVP (the `trusted` level is
  reserved for future work).
- Default trust is `untrusted`; operators must opt in to anything
  more permissive.
- Egress lockdown is application-layer: a 3xx redirect from upstream
  to a *different* host is rejected with HTTP 502. The MVP does
  not enforce an apptainer `--net` policy.
- Runs in `sac-proxy.sif` — see `containers/sac-proxy.def`.

See [`examples/agents/proxy-agent/spec.yaml`](https://github.com/scitex-ai/scitex-agent-container/blob/develop/examples/agents/proxy-agent/spec.yaml)
for a complete minimal example.

## Examples

Copy from [`examples/agents/`](https://github.com/scitex-ai/scitex-agent-container/tree/develop/examples/agents):

- [`full-agent/`](https://github.com/scitex-ai/scitex-agent-container/tree/develop/examples/agents/full-agent) — annotated spec exercising every supported field (plus `to_home/` layout)
- [`minimal-agent/`](https://github.com/scitex-ai/scitex-agent-container/tree/develop/examples/agents/minimal-agent) — bare minimum, no `to_home`
- [`hello-agent/`](https://github.com/scitex-ai/scitex-agent-container/tree/develop/examples/agents/hello-agent) — quickstart with `startup_prompts`
- [`proxy-agent/`](https://github.com/scitex-ai/scitex-agent-container/tree/develop/examples/agents/proxy-agent) — `kind: AgentProxy` forwarder example
