# Tools and tool policy

loomcycle exposes tools to agents through a **two-layer default-deny model**: every tool is disabled at the operator layer until env-configured, and every agent gets zero tools at the agent layer until `tools` is set in YAML. Both layers must say "yes" before a tool reaches the model.

This document explains the model end-to-end and what it means for operators wiring up new agents or new tools.

## The two layers

```
┌──────────────────────────────────────────────────────────────┐
│ Layer 1 — Operator (server-wide)                             │
│   Built-ins refuse every call when their env is unset.       │
│   MCP servers must be declared in mcp_servers in YAML, with  │
│   optional per-server tools narrowing.               │
└──────────────────────┬───────────────────────────────────────┘
                       │ (registered + enabled tools)
                       ▼
┌──────────────────────────────────────────────────────────────┐
│ Layer 2 — Agent (per-run)                                    │
│   Agent's tools in YAML lists the names exposed.     │
│   Empty / missing list → zero tools, full stop.              │
│   Caller's request body may further narrow (intersection).   │
└──────────────────────┬───────────────────────────────────────┘
                       │ (effective tool set)
                       ▼
                   Dispatcher
```

Both layers are intersection-only: nothing the model sees can ever exceed what the operator allowed at layer 1, and nothing exceeds what the agent definition allowed at layer 2.

## Layer 1 — operator-side: enabling tools

### Built-ins

Each built-in is registered into the dispatcher at process startup but **refuses every call** until its sandbox is configured. For the file/exec tools that means a bound **volume** (RFC AH); for the network tools, a host allowlist or key. The model sees the tool's spec and may try to call it, but every call comes back with `{"is_error": true, "text": "<tool> ... refuses ..."}`. This deliberate behaviour means the model gets a clear "not configured" signal it can self-correct from, instead of `unknown tool`.

| Tool        | Enabled by                                            |
|-------------|-------------------------------------------------------|
| `Read`      | a bound `volumes:` volume (any mode)                  |
| `Write`     | a bound **rw** `volumes:` volume                      |
| `Edit`      | a bound **rw** `volumes:` volume                      |
| `Grep`      | a bound `volumes:` volume (any mode)                  |
| `Glob`      | a bound `volumes:` volume (any mode)                  |
| `NotebookEdit` | a bound **rw** `volumes:` volume                   |
| `HTTP`      | `LOOMCYCLE_HTTP_HOST_ALLOWLIST=api.example.com,...`   |
| `WebFetch`  | (same allowlist as HTTP — shared backend)             |
| `WebSearch` | `BRAVE_API_KEY=...`                                   |
| `Bash`      | `LOOMCYCLE_BASH_ENABLED=1` + a bound **rw** volume    |
| `Bashbox`   | `LOOMCYCLE_BASHBOX_ENABLED=1` + a bound volume (**ro or rw** — a true in-process sandbox) |
| `Agent`     | Always registered (server-internal); per-agent `tools` controls who can spawn. |
| `Skill`     | `LOOMCYCLE_SKILLS_ROOT=/path/to/skills` (or skills inlined per-agent via YAML `skills:` map, or the `skill_defs` substrate). Auto-added to every agent that may use a skill (RFC BA); loads bodies on demand. Gated by the agent's `skills:` pattern allowlist. |
| `SkillDef`  | Always registered (v0.8.22). Authoring gated by the agent's `skills:` pattern allowlist (RFC BA — empty = allow all, `-*` = deny all); no extra env var. Storage shared with the rest of the substrate. |
| `VolumeDef` | Always registered (RFC AH Phase 2a/2b). Per-agent `volume_def_scopes:` YAML gate (default-deny) for create/delete/purge; get/list are tenant-scoped reads. `create {ephemeral:true}` provisions a run-scoped volume auto-purged at top-level-run completion. Requires a static volume marked `dynamic_root: true`. Storage shared with the rest of the substrate. |
| `Memory`    | Storage backend (SQLite default; Postgres opt-in) + per-agent `memory_scopes:` allowlist. |
| `Document`  | Always registered (RFC AK Phase 1). Per-agent `tools:[Document]`. **Requires SQL Memory** (`LOOMCYCLE_SQLMEM_ENABLED=1`). Chunked-graph documents: chunk **bodies+fields** in Memory (keyed by UUID), **structure** (hierarchy/type/status/edges/type-schemas) in SQL Memory (4 tables). 13 ops (document/chunk lifecycle, edges, `query_chunks` with structured filters + `under_path` Path join + a validator-gated `sql:` escape hatch, type defs). Optimistic `revision` on update; delete cascades descendants + their Memory bodies. Names documents in the Path tree (`create_document path:`). Scope **agent/user** (tenant deferred); tenant-isolated via the SQL Memory scope key. |
| `Path`      | Always registered (RFC AL). Per-agent `tools:[Path]`. A Unix-like VFS (`resolve`/`ls`/`stat`/`mkdir`/`mv`/`rm`) over the `dirents` runtime-store table, naming Memory entries / Volume mounts / Documents by tenant-rooted, scope-aware (agent/user/tenant) paths. Resources keep native ids; a dirent is a name, not an authority grant. Paths reject `..`; tenant-isolated. Resources opt in via `Memory.set path:` / `VolumeDef.create mount_at:` / `Document.create_document path:`. |
| `Context`   | Auto-added to every agent unless `disable_context: true`. Read-only runtime introspection; no side effects. 12 ops: `self` / `tools` / `doc` / `permissions` / `agents` / `lineage` / `evaluations` / `channels` / `help` / `time` / `compact` / `capabilities`. `self` reports identity + resolved provider/model/sampling, the bound `volumes.bindings`, the non-secret `principal` block (never the bearer), the `server` it is connected to, and a **`loomcycle`** block with the running build (`version` / `commit` / `build_time`, threaded from `main` so it always matches `--version`). **`capabilities`** reports what this deployment actually supports — see below. |
| `History`   | Always registered (RFC BE). Per-agent `history_scope:` YAML gate (default-deny). Browse/search/annotate PAST chats (a chat = a session). 10 ops (`list`/`get`/`search`/`rename`/`annotate`/`pin`/`archive`/`recap`/`resume`/`related`); `get format:markdown` exports the transcript; `recap` stores a refreshable LLM summary on the chat; `resume` returns a continuation handle; `related` finds semantically similar chats via the vector embedder (gated on an embedder; index fills lazily as chats are recapped/renamed/annotated). Owner-scope-aware — `self`/`user`/`tenant`/`global`, with the owner resolved server-side from the run identity and `global` (cross-tenant) admin-only. Every by-id read folds a cross-scope row to an opaque not-found. Per-chat token/cost/run-count stats included; transcripts are already-redacted at write time. Supersedes the removed `Context op=history`. See `docs/HISTORY.md`. |

Bash has additional warnings: it is **not a true sandbox** even when enabled. Run loomcycle inside a container or VM if Bash is exposed to untrusted prompts. See `internal/tools/builtin/bash.go` for the full warning.

`Bashbox` is the **true in-process sandbox** alternative to `Bash` (RFC AJ): it runs the command via gbash (pure-Go), spawning **no OS process**, rooting all paths at the mounted volume, with **no network**. Because the isolation is real, it **honors read-only volumes** — a `ro` binding mounts under an in-RAM write overlay, so writes succeed in-run but never touch the host (`Bash`, which cannot enforce ro, refuses a `ro` volume). Prefer `Bashbox` over `Bash` for untrusted prompts or read-only work; use `Bash` only when you need an actual host binary. It bundles the pure-Go `awk`/`jq` contrib commands on top of gbash's coreutils builtins (~97% `/bin/sh` parity; `git`/network are absent by default). gbash is alpha and pinned; the per-agent `tools` gate is the escape hatch. See the `bashbox` help topic.

**Host-command fallback (RFC AJ §13, operator opt-in).** Commands gbash doesn't implement (`git`, `gh`, …) normally fail. An operator can allowlist specific ones to run on the **real host shell** via `LOOMCYCLE_BASHBOX_FALLBACK_COMMANDS=git,gh` — **only** those names escape the sandbox; every other command stays sandboxed (so `git status; curl …` runs git on the host but `curl` in the sandbox — no smuggling). Credentials reach those commands via `LOOMCYCLE_BASHBOX_FALLBACK_ALLOWED_ENV=GH_TOKEN,HOME,SSH_AUTH_SOCK` (injected only into the host child, never the sandbox env — the model can't read them via `env`). Fallback **requires a read-write volume** (a host process can't honor the read-only overlay → a fallback command on a `ro` volume refuses), runs with the working directory mapped to the host path for the script's cwd (containment-checked), and inherently has host filesystem + network. Off by default; a loud boot warning fires when configured.

**`Path` + `Document` are reachable off-run, on every transport (v1.4.0).** Besides being callable in-band by an agent, both are exposed as direct operations — `POST /v1/_path` + `POST /v1/_document` (HTTP), the `Path` / `Document` gRPC RPCs, the LoomCycle MCP meta-tools `path` / `document`, and `client.path()` / `client.document()` in the TS (`@loomcycle/client`) and Python adapters — so a human or UI can co-author the same scoped namespace + documents agents build, without spawning a run. All four surfaces dispatch through one op-discriminated `Connector` method per tool; **scope (agent/user/tenant) and tenant are resolved server-side from the authenticated principal, never the wire**. Both are tenant-confined (`ScopeTenant`; `substrate:admin` also satisfies). See `docs/PATH.md` and `docs/DOCUMENTS.md`.

**`History` is reachable off-run via `POST /v1/_history`, the LoomCycle MCP meta-tool `history`, the gRPC RPC, and the TS/Python adapter twins (RFC BE).** Same posture as Path/Document — one op-discriminated `Connector` method, owner-scope + tenant resolved server-side from the principal, `ScopeTenant`-gated (admin also satisfies), and the cross-tenant `global` scope admin-only. `related` is a new op on the same dispatch (no wire change). See `docs/HISTORY.md`.

Sandbox semantics (file tools):
- Paths must resolve **inside** the sandbox root after full `EvalSymlinks` evaluation. Symlinks pointing outside the root are refused.
- **Relative paths are joined to the bound volume's root, NOT the loomcycle process's current working directory.** A relative `Read`/`Edit`/`Grep`/`Glob`/`Write` path resolves under the volume the call targets (`internal/tools/builtin/sandbox.go` `absUnderRoot`). This is what the model assumes and means agents should pass **relative paths** (e.g. `src/main.go`) regardless of where loomcycle was launched from. `~` is **not** expanded; an absolute path is accepted only if it still resolves inside the root. (The older behaviour joined relative paths to the process CWD, so a relative path could land outside the jail — the offset that scattered files in the experiments harness and made a code-reviewer's `Read internal/store/store.go` resolve into a stray binary at the launch dir.) `Bash` runs with its working directory set to the bound (rw) volume's root, so relative paths in a shell command are relative to *that* — the same root the file tools use, so paths agree. Agents read their bound volumes at runtime via `Context op=self` (`volumes.bindings`).
- `Write` resolves the **parent** dir (target may not exist yet); `Read`, `Edit`, `Grep`, `Glob`, and `NotebookEdit` resolve the target itself.
- All file writes are atomic via tempfile + same-directory rename.
- `Grep` skips binary files via NUL-byte heuristic on the first 8 KiB; caps output at 256 KiB + `head_limit` (default 100).
- `Glob` supports `**` for recursive segment match; returns paths sorted by mtime DESC, capped at 100 results.
- `NotebookEdit` accepts `replace` / `insert` / `delete` modes; preserves all non-target cells and notebook metadata verbatim.
- **Filesystem Volumes (RFC AH).** Volumes are the **sole** filesystem mechanism — the operator declares a top-level `volumes:` map (named ro/rw roots) and binds an agent via `AgentDef.volumes`; the file/exec tools resolve paths against those volumes. Each of `Read` / `Write` / `Edit` / `Glob` / `Grep` / `Bash` / `Bashbox` / `NotebookEdit` accepts an optional `"volume"` string argument:
  - **omitted** → the agent's designated default binding (the one marked `default: true`, or the sole binding). Multiple bindings + no designated default → an error listing the available names.
  - **named** → that binding; naming a volume the agent isn't bound to → an error listing the bound volumes.
  - **ro / rw**: `Read` / `Glob` / `Grep` operate on any volume; `Write` / `Edit` / `NotebookEdit` and **`Bash`** require a `rw` volume (a `ro` target is refused — `Bash` cannot enforce read-only, so it refuses rather than ship a false guarantee). **`Bashbox`** is the exception — being a true in-process sandbox it **honors** a `ro` volume (writes hit an in-RAM overlay, never the host). The unchanged `resolveInsideRoot` containment still applies per-volume — a `..` escape from one volume into another is rejected.
  - **Sandbox-by-default (RFC AH Phase 3).** An agent with **no** volume binding (or a deployment with no `volumes:` config) has **no filesystem access** — every file/exec tool refuses. The legacy `LOOMCYCLE_READ_ROOT` / `WRITE_ROOT` / `BASH_CWD` jail is retired (setting one fails startup with a migration hint; declare a `default` volume to restore the single shared jail). Sub-agents inherit the **narrow-only** intersection of the parent's volumes. Agents read their bound volumes at runtime via `Context op=self` (`volumes.bindings`: name / path / mode / default); an unbound agent sees `filesystem: "none …"`. See `docs/CONFIGURATION.md` §9d.
- **`VolumeDef` — dynamic volumes (RFC AH Phase 2a/2b).** The `VolumeDef` tool provisions *dynamic* volumes at runtime: tenant-scoped, mutable, and confined inside an operator-blessed parent (a static volume marked `dynamic_root: true`). Ops: `create {name, mode}` (the path is **runtime-derived** — `<dynamic_root>/<tenant-segment>/<name>`; you supply a name + mode, never a host path; idempotent, mode-updates), `get {name}` / `list` (tenant-scoped reads), `delete {name}` (unmap — **leaves files**), `purge {name}` (unmap **and** delete the directory tree, behind a four-way fence: tenant ownership, re-derive-don't-trust, `EvalSymlinks`-the-real-path, assert-strictly-inside-root). **Phase 2b** adds `create {name, mode, ephemeral:true}` — a **run-scoped** volume at `<dynamic_root>/_ephemeral/<root_run_id>/<name>`, resolvable by the whole creating run tree and **auto-purged when the top-level run completes** (a singleton sweeper backstops crashes; `LOOMCYCLE_EPHEMERAL_VOLUME_SWEEP_MS`); it requires an active run and has no `delete`/`purge` (lifetime is the run). Names must match `^[a-z0-9][a-z0-9_-]{0,63}$`. Gated by per-agent `volume_def_scopes` (`any` / `named:<volume>`; default-deny). An agent binds to a dynamic volume by name exactly like a static one. See `docs/CONFIGURATION.md` §9d.1/§9d.2 and the `volumedef` help topic.

SSRF semantics (network tools):
- `HostAllowlist` is **suffix-anchored** at a dot boundary: an entry `example.com` matches `example.com` and `api.example.com` but not `evilexample.com`.
- After DNS resolution, **private IPs are hard-blocked regardless of allowlist** — RFC1918, loopback, link-local (including the AWS/GCP metadata service at `169.254.169.254`), multicast, and IPv6 ULAs. This defeats DNS rebinding.
- Redirects re-validate the destination against the same allowlist on every hop.

### MCP servers

MCP servers are declared in YAML under `mcp_servers`. Each server's tools are registered as `mcp__{server}__{tool}` after the server's `tools/list` discovery completes.

Per-server narrowing at the operator layer:

```yaml
mcp_servers:
  brave-search:
    transport: stdio
    command: npx
    args: [-y, "@modelcontextprotocol/server-brave-search"]
    env: { BRAVE_API_KEY: "${BRAVE_API_KEY}" }
    # Operator-level filter: only these of the server's tools are
    # registered AT ALL. Others are invisible to every agent.
    tools: [brave_web_search]
```

Without `tools` on the server entry, every tool the server advertises is registered. With it, only the listed tools are registered, and the rest are dropped before any agent can ever request them.

## Layer 2 — agent-side: exposing tools

Each agent definition lists which tools it can access. The list is **anchored**: anything not on it is invisible.

```yaml
agents:
  cv-adapter:
    model: smart
    tools:
      - Read
      - Edit
      - "mcp__brave-search__*"   # glob — every brave-search tool that's registered
    system_prompt_file: ./prompts/cv-adapter.md

  ats-filter:
    model: local
    tools: [Read]         # only reads files
```

If you forget `tools`, the agent gets zero tools. The startup log will warn:

```
note: agent "myagent" has no tools — it will see zero tools (intentional default-deny; add tools to its YAML to expose tools)
```

This is a feature, not a bug. New agents start with no privileges; you grant them.

### Glob support

Entries ending in `*` match by prefix. The canonical use is exposing all tools from one MCP server: `"mcp__brave-search__*"`. Built-in names are short and unambiguous so globs aren't usually needed for them, but the same machinery applies.

> Adding a new skill, tool, or MCP server to an existing agent (and the rules on *who* may widen a tool ceiling) is covered end-to-end in [`docs/CUSTOMIZING_AGENTS.md`](CUSTOMIZING_AGENTS.md).

### Caller-side narrowing

The HTTP request can carry `tools` in the body to further narrow the agent's set on a single run:

```http
POST /v1/runs
{
  "agent": "cv-adapter",
  "tools": ["Read"],         // narrowing — only Read for THIS run
  "segments": [...]
}
```

The effective set is the **intersection**: agent allowlist ∩ caller allowlist. The caller can shrink the agent's privileges for one run; it can never widen them.

Use cases:
- Multi-step pipelines where stage 1 only reads, stage 2 also writes.
- A web UI that exposes a "safe mode" that disables `Bash` even when the agent is normally allowed it.

## Per-request URL allowlist (HTTP / WebFetch / WebSearch)

The operator's `LOOMCYCLE_HTTP_HOST_ALLOWLIST` is a security floor — every host the agent might ever need to reach. It is a comma-separated suffix list (`example.com` matches `example.com` and `api.example.com`). A single `*` entry (`LOOMCYCLE_HTTP_HOST_ALLOWLIST=*`) is the operator's explicit **allow-all**: any hostname passes the name check. This lifts only the name layer — the dial-time IP guard still blocks private/loopback/link-local/metadata addresses, so `*` means "all **public** websites", never "all addresses"; expose specific internal hosts via `LOOMCYCLE_HTTP_PRIVATE_HOST_ALLOWLIST`. A caller cannot use `*` to widen a narrower operator floor — the intersection below still applies.

For a single run, callers usually want to constrain further: this run is about scraping job listings from `linkedin.com` and `indeed.com`, that one is about reading from `bbc.co.uk`. Per-request narrowing exists for exactly this.

```http
POST /v1/runs
{
  "agent": "cv-adapter",
  "allowed_hosts": ["linkedin.com", "indeed.com"],
  "web_search_filter": "drop",       // optional; defaults to "drop"
  "segments": [...]
}
```

### Three-state `allowed_hosts`

| Field state           | Effective allowlist                                  |
|-----------------------|------------------------------------------------------|
| omitted (`null`)      | Operator's full static list (no narrowing)           |
| `[]` (empty array)    | **Deny all** — every HTTP / WebFetch call refuses    |
| `["a.com", "b.com"]`  | Operator list ∩ caller list                          |

Distinguishing `null` from `[]` matters: omitting the field means "I don't have an opinion, use the operator's list"; sending `[]` means "this run does no network calls".

### Intersection-only — caller can shrink, never widen

If the operator's static allowlist is `["api.linkedin.com"]` and the caller asks for `["evil.example", "api.linkedin.com"]`, the effective list is `["api.linkedin.com"]` — `evil.example` is silently dropped. The operator's policy is the floor; nothing the caller does can lift it. Suffix matching applies on both sides: `api.linkedin.com` is allowed under operator entry `linkedin.com`.

### Trust boundary

`allowed_hosts` MUST come from the trusted upstream caller, never from the model. The model can read its own `system` prompt and request body content but cannot construct a /v1/runs request. As long as the application calling loomcycle is the one supplying `allowed_hosts`, the threat model holds. **Don't pipe model-generated text into `allowed_hosts`** — that turns the policy into something the prompt can manipulate.

### `web_search_filter` for WebSearch

Brave Search returns whatever it found. With per-request narrowing in place, the model sees URLs it can't actually fetch, which wastes context tokens. Two options:

| `web_search_filter` | Brave behaviour preserved | Model sees results outside `allowed_hosts`? |
|---------------------|---------------------------|----------------------------------------------|
| `"drop"` (default when `allowed_hosts` is set) | Yes (Brave is paid) | No — non-matching results are filtered out, indices renumber |
| `"keep"`            | Yes                       | Yes — full result list passes through; caller filters downstream |

`drop` is the default because the typical agent is already context-constrained and showing it URLs it can't follow up on is wasteful. `keep` is for callers that want to see what Brave returned before applying their own narrowing logic (e.g. a UI that displays "we found N results on these other hosts too").

Filter mode is ignored when `allowed_hosts` is `null`.

### Session continuation

Continuations on `/v1/sessions/{id}/messages` re-supply `allowed_hosts` and `web_search_filter` per call. The list is **not** persisted on the session. This keeps "what hosts can this turn reach?" answerable from the request alone — no implicit state to chase. A continuation can change (typically narrow) the list as the conversation evolves.

### What this doesn't cover

- **Path-level filtering** (`example.com/api/v1/*` only) — out of scope; operators wanting that wire an HTTP MCP gateway in front.
- **Per-host method limits** (`linkedin.com` GET-only) — same reasoning.
- **Quota / rate limits per host** — orthogonal; belongs in a v0.4 hooks/observability story.

## What "default-deny" means in practice

| Situation                                                 | Result                                |
|-----------------------------------------------------------|---------------------------------------|
| Agent bound to no (rw) `volumes:` volume                  | `Write`/`Edit`/`Bash`/etc. refuse with a clear "no filesystem volume available" message. |
| Agent omits `tools`                               | The model sees zero tools.            |
| Agent includes a tool the operator hasn't enabled         | The tool is registered (visible) but every call refuses. |
| Caller's request lists a tool the agent doesn't allow     | That tool is silently dropped from the run's set. |
| Agent globs `mcp__foo__*` but no such server is declared  | No tools — server isn't registered.   |

## Design rationale

**Why register tools the operator hasn't enabled?** So the model sees `{tool} is not configured` instead of `unknown tool`. The first is a signal the model can self-correct from ("oh, I shouldn't try that here"); the second is a confusing dead end.

**Why default-deny at the agent layer?** Adding a new built-in shouldn't silently grant every existing agent new privileges. With default-deny, new tools require explicit agent opt-in.

**Why allow caller narrowing but not widening?** The agent definition is the trust contract between operator and the runtime; the caller is upstream code that can reduce trust for one call but not raise it. This matches the principle that policy decisions flow inward (operator → agent → caller) and trust never compounds in the other direction.

## Testing the policy

Unit tests in `internal/tools/policy/policy_test.go` cover the matching rules (exact, glob, intersection).

Integration tests in `internal/api/http/server_test.go`:
- `TestAgentWithNoAllowedToolsSeesZeroTools` — empty allowlist → zero tools at the dispatcher.
- `TestAgentWithExplicitAllowSeeTool` — positive control: allow-listed tool reaches the model.

Empirical proof of the security invariant: inverting the default in `filterTools` (so empty allowlist exposes every tool) makes the first test fail. The test is what stops a future refactor from accidentally inverting the policy.

## The `Agent` tool — sub-agent spawning (v0.4.0)

The `Agent` built-in lets a parent agent spawn a child run by name:

```json
{"name": "researcher", "prompt": "Investigate X and return JSON …"}
```

**Auto-registration.** Unlike other built-ins, `Agent` is registered automatically by the HTTP server (it has to close over the server's own sub-run runner). It still respects per-agent `tools` — only agents that list `Agent` in their YAML can spawn children.

**What the child inherits from the parent (via ctx):**

- `user_id` — sub-agents bill to the same user.
- `parent_agent_id` — written to the child's run row; powers cascade-cancel.
- **Caller-authoritative host policy** (`allowed_hosts` + `web_search_filter`) — the parent's per-call host narrowing flows into the child. Without this propagation, sub-agents would silently fall back to the operator's static `LOOMCYCLE_HTTP_HOST_ALLOWLIST`, which usually excludes localhost callbacks. The fix landed in v0.4.0; see `internal/api/http/server.go runSubAgent` and `internal/tools/tool.go HostPolicy`.

**What the child does NOT inherit:**

- Tool allowlist — the child's `tools` is its YAML definition, narrowed only by the operator's enabled set. Parents cannot widen.
- Session — sub-agents get a fresh session.
- Tenant — inherited only for `user_id` purposes; multi-tenant isolation rules apply equally.

**Recursion cap.** `MaxAgentDepth = 16` by default. Deeper spawning attempts return `IsError: true` tool_results so the parent can decide to retry / fall back / give up.

**Failure mode is a tool error, not a run failure.** A sub-agent that crashes, times out, or rejects its input returns an `IsError: true` tool_result. Loomcycle does NOT tear down the parent on a child failure — the parent sees the error and decides what to do.

References: `internal/tools/builtin/agent.go`, `internal/api/http/server.go runSubAgent`, `internal/api/http/agent_subagent_test.go`.

## The `Skill` tool — on-demand skills with a per-agent allowlist (RFC BA)

Skills are loaded **on demand** via the `Skill` tool, not bundled into the
system prompt. Skill sources are the filesystem (`LOOMCYCLE_SKILLS_ROOT`, with
nested `/`-grouped dirs like `<root>/doc/redactor/SKILL.md`), inline per-agent
YAML (`skills:` map), and the runtime `skill_defs` substrate — assembled into one
catalog the tool serves.

**`skills:` is a pattern ALLOWLIST**, not a bundle list. It governs which skills
the agent may **list, use (invoke), and author (SkillDef)** — one policy for all
three. Entry forms: `pat`/`+pat` (allow), `-pat` (deny), where `pat` is a
`/`-segmented glob (`doc/*`, `marketing/**`, exact `seo`). Evaluation: a name is
allowed iff (there is a positive entry it matches, or there are none) AND no
negative matches. **Empty/absent `skills:` = allow all**; `-*` = allow nothing.

```yaml
agents:
  doc/manager:
    model: smart
    tools: [Document]          # `Skill` is auto-added unless skills: [-*]
    skills: [doc/*, -doc/secret]   # may list/use/author doc/* except doc/secret
```

Every agent that may use any skill gets the `Skill` tool auto-added (mirror of
the `Context` auto-add); only `skills: [-*]` skips it. When `skills:` is a
whitelist, a short per-run note names the permitted patterns.

Wire shape (op-discriminated; `op` defaults to `invoke` for back-compat):

```jsonc
{
  "op":      "invoke" | "list",   // default invoke
  "name":    "doc/redactor",       // op=invoke: the skill to load
  "pattern": "doc/*"               // op=list: optional /-glob filter
}
```

- `op=list` → the catalog **visible to this agent** = assembled catalog ∩ the
  agent's `skills:` allowlist ∩ optional `pattern`, as `[{name, description}]`.
- `op=invoke` (or a bare `{name}`) → the skill body as a tool_result, refused if
  the name is outside the allowlist.

Constraint (unchanged): a skill's own `allowed-tools`/`tools` must be a **subset**
of the invoking agent's `tools`; a widening skill is refused at invoke time.

References: `internal/skills/` (loader), `internal/skillmatch/` (allowlist +
name/pattern validation), `internal/tools/builtin/skill.go`.

## `Context op=capabilities` — what this deployment supports (v1.34.0)

Loomcycle deployments differ: one has pgvector and an embedder, another has
neither; one enables Bash, another only the sandbox. Before v1.34.0 an agent
found out by *calling* and reading a refusal (`vector_unsupported`,
`embedder_not_configured`, `capability_unsupported`, `SQL Memory is not
enabled …`). `op=capabilities` lets a client **branch before calling**.

```jsonc
{
  "vector_memory":    {"available": true,
                       "embedder": {"provider": "ollama-local",
                                    "model": "embeddinggemma", "dimension": 768}},
  "full_text_memory": {"available": true},
  "memory_layer":     {"available": true},
  "sql_memory":       {"available": true},
  "documents":        {"available": true},
  "bash":             {"available": false},
  "bashbox":          {"available": true},
  "sandbox":          {"available": false},
  "scheduler":        {"available": true},
  "webhooks":         {"available": false},
  "retention":        {"available": false},
  "code_js":          {"available": false},
  "search":           {"available": true, "providers": ["brave", "searxng"]},
  "consolidation":    {"available": false, "configured": true,
                       "merge_threshold": 0.95, "related_threshold": 0.85},
  "storage":          {"backend": "postgres"},          // admin-only
  "limits": {
    "max_request_bytes": 16777216,
    "max_consolidation_targets": 32,
    "max_consolidation_concurrency": 4,
    "memory_inject_max_tokens": 1024
  }
}
```

Every field is read from **live runtime state**, not asserted:

- `vector_memory` is end-to-end — a vector-capable store **and** an embedder.
  The `dimension` is reported because an agent needs it to know whether stored
  vectors are comparable.
- `sql_memory` / `documents` read the **constructed** SQL-Memory manager, not
  the config flag: the flag says the operator asked for it, the pointer says
  they got it.
- `scheduler` / `webhooks` mirror the **boot** condition (flag **and** a store),
  so an agent authoring a `ScheduleDef` is never told it will fire when it
  won't.
- `sandbox` has no config flag — it arrives as MCP tools from a builder
  sidecar — so presence in *this run's* tool list is the truth, making it
  per-run by nature.
- `consolidation` separates `configured` from `available`: a staged-off
  consolidator means queued `Memory op=add` items are durable but nothing is
  draining them — the state that otherwise looks like "recall is broken". It
  also carries the two similarity bands as **effective** values (an unset band
  reads as the number in force, never as `0`), because cosine scale is a
  property of the embedding model: a band this deployment's embedder never
  reaches means merging can never fire, and nothing else makes that visible.
  Measure yours with [`loomcycle memory-calibrate`](#calibrating-the-bands--loomcycle-memory-calibrate).

**Security posture.** This surface is readable by every agent and every MCP
client, so two rules are absolute and CI-enforced by tests driven with planted
values:

1. **No secrets** — no API keys, tokens, DSNs or credential values, and no
   `api_key_env` **names** either (the variable an operator chose is itself a
   hint about their key management).
2. **No infrastructure topology** — no base URLs, hostnames, IPs, ports, file
   paths or container detail. `provider: ollama-local` is a capability;
   `http://192.168.0.77:11434` is a map of the operator's network. This is why
   the `search` block emits only the configured provider **names**: the config
   value behind each carries a SearXNG `base_url`.

**Tenant posture** mirrors the routing view: availability reaches **every**
caller — a tenant needs it to branch — and only `storage`, which is pure
operator infrastructure and never needed to decide whether a call will work, is
admin-gated. A `substrate:tenant` caller's output is therefore always a subset
of an admin's.

Available in-band to any agent holding `Context`, and over MCP as
`context {"op":"capabilities"}`.

## The `Memory` tool — persistent agent-scoped storage (v0.8.0)

The `Memory` built-in gives agents a place to write down state that survives across runs and sessions. Six operations behind one tool, discriminated by an `op` field: **`get`**, **`set`**, **`delete`**, **`list`**, **`incr`**, **`search`** (v0.9.0).

### Wire shape

```jsonc
{
  "op":     "get" | "set" | "delete" | "list" | "incr" | "search",
  "scope":  "agent" | "user",
  "key":    "string",     // get/set/delete/incr
  "value":  any,          // set
  "delta":  number,       // incr (default 1, may be negative)
  "ttl":    number,       // set/incr — seconds; absent = no expiry
  "prefix": "string",     // list/search filter
  "limit":  number,       // list cap (default 100, max 1000)
  // v0.9.0 Vector Memory:
  "embed":      bool,     // set — also store an embedding for this row
  "embed_text": "string", // set — text to embed (defaults to JSON-stringified value)
  "query":      "string", // search — text to embed and use as similarity query
  "top_k":      number    // search — max results (default 10, max 50)
}
```

Result shapes:

```jsonc
get    → { "value": <stored> | null, "expires_at": "RFC3339" | null }
set    → { "ok": true, "embedded"?: bool, "embed_warning"?: "string" }
incr   → { "value": <new int> }
delete → { "deleted": true | false }
list   → { "entries": [{"key", "value", "expires_at"}], "truncated": bool }
search → { "entries": [{"key", "value", "score", "embedded_with", "expires_at"}],
           "query_embedding_dim": number, "truncated": bool }
```

### Scopes

Two scopes ship in v0.8.0:

- **`agent`** — keyed by the yaml-declared agent name. Cross-run, **shared across users.** Use for: per-agent counters, learned heuristics, summaries the agent wants its future self to read.
- **`user`** — keyed by the run's `user_id`. Cross-agent, **per end-user.** Use for: voice/preferences, per-user notes, anything you want every agent that's allowed to read the user scope to see.

`session` and `tenant` scopes are forward-compatible (the yaml allowlist accepts new scope strings without a wire-protocol change) but not implemented in v0.8.0.

`scope_id` is **always** resolved server-side. The model picks the scope; loomcycle picks the scope_id. A model-supplied scope_id would let one user's agent run read another user's keys.

### Per-agent yaml policy

```yaml
agents:
  cv-adapter:
    tools: [Memory, Read, Write]
    memory_scopes: [agent, user]            # which scopes this agent may use
    memory_quota_bytes: 5_000_000           # per-(scope, scope_id) override (default 1 MB)
```

`memory_scopes` is a default-deny allowlist. An agent with `Memory` in `tools` but no `memory_scopes` sees a refusal on every Memory call.

`memory_quota_bytes` overrides the global `LOOMCYCLE_MEMORY_MAX_SCOPE_BYTES` for this agent only. Use higher caps for memory-heavy agents; lower for noisy agents you want kept in check.

### Operator env vars

| Env var | Default | Purpose |
|---|---|---|
| `LOOMCYCLE_MEMORY_MAX_VALUE_BYTES` | `65536` | Per-write cap on the `value` payload. 0 disables. |
| `LOOMCYCLE_MEMORY_MAX_SCOPE_BYTES` | `1048576` | Default per-(scope, scope_id) cap. 0 disables. |
| `LOOMCYCLE_MEMORY_SWEEP_MS` | `900000` (15 min) | TTL reaper goroutine cadence. 0 disables. |

### Atomic increment

`op: "incr"` is the counter primitive. If the key doesn't exist (or the existing value has already expired), the row is created starting from `0 + delta`. If the existing value isn't a JSON number, the call is refused with `wrong_type`. Optional `ttl` resets the expiry on every increment.

### TTL semantics

`ttl: 3600` on a `set` makes the row expire in one hour. Reads filter expired rows out **before** the sweeper runs them — agents never see stale values, even on a slow sweep cadence. The sweep goroutine just keeps the table bounded over the long haul.

### Vector / semantic search (v0.9.0)

The `search` op turns Memory into a retrieval substrate: agents `set` a row with `embed: true` and `embed_text: "..."`, then later `search` for rows by **semantic similarity** to a query. Exact-key lookup (`get`, `list`) is for state retrieval; semantic search is for "what have I learned that's *close to* this?"

```jsonc
// embedding-on-set
{
  "op": "set", "scope": "agent", "key": "rec1",
  "value": { "name": "Alice", "skills": ["Go", "Rust"] },
  "embed": true, "embed_text": "Alice is a Go and Rust developer"
}
// → { "ok": true, "embedded": true }

// search
{
  "op": "search", "scope": "agent",
  "query": "systems programmer",
  "top_k": 5
}
// → { "entries": [
//       { "key": "rec1", "value": {...}, "score": 0.91,
//         "embedded_with": {"provider": "openai", "model": "text-embedding-3-large"},
//         "expires_at": null }, ... ],
//     "query_embedding_dim": 3072, "truncated": false }
```

**Score** is cosine similarity in `[0, 1]` (higher = closer). Backends convert from their native distance function before returning.

**top_k** defaults to 10 and is hard-capped at 50 (RFC §6). Use the `prefix` field to scope `search` to a key-prefix the same way `list` does.

**`embedded_with`** carries the `(provider, model)` of the row's stored embedding so callers can spot rows under an older embedder before running a reembed migration.

#### Configuration

Two pieces of operator config gate vector ops:

1. **Backend** — Postgres with the `pgvector` extension is the only supported backend in v0.9.0. SQLite refuses with `vector_unsupported` (sqlite-vec ships in v0.9.1). Set `LOOMCYCLE_PGVECTOR_ENABLED=1` to opt in; loomcycle probes `pg_extension` at boot and refuses to start when the extension isn't installed.
2. **Embedder** — exactly one embedder per loomcycle instance, declared in yaml:

   ```yaml
   memory:
     embedder:
       provider: openai     # openai | gemini | anthropic (Voyage proxy) | ollama-local | ollama
       model: text-embedding-3-large
       base_url: ""         # optional; self-hosted / alternative endpoint
       api_key_env: ""      # optional; env-var NAME holding the credential
       dimensions: 0        # optional; 0 = the model's native vector width
       timeout_ms: 30000    # optional; env fallback LOOMCYCLE_MEMORY_EMBED_TIMEOUT_MS
       batch_size: 100      # optional; env fallback LOOMCYCLE_MEMORY_EMBED_BATCH_SIZE
   ```

When `memory.embedder:` is unset, the `search` op and `embed: true` on `set` refuse with `embedder_not_configured`. The k/v ops are unaffected — operators not wanting semantic search don't have to configure anything.

**Anthropic embedder note:** v0.9.0 ships an Anthropic *stub* that refuses with `embedder_not_implemented`. Anthropic has no native embeddings API today; v0.9.1 will wire Voyage AI under this driver name. Use `openai` or `gemini` for v0.9.0.

##### Self-hosted embedders

Vector Memory does not require a vendor account. Two routes:

**Native Ollama.** `provider: ollama-local` speaks Ollama's `POST /api/embed` directly, keyless:

```yaml
memory:
  embedder:
    provider: ollama-local
    model: embeddinggemma:latest
    # base_url inherits OLLAMA_BASE_URL; set it explicitly to override
```

- **You must pull an embedding model first — this is required, not optional.** A stock Ollama ships none, and every call 404s until you run `ollama pull embeddinggemma` (or `nomic-embed-text`, `qwen3-embedding`, …) on the Ollama host. The error names the model and the pull command.
- `base_url` must be reachable **from inside the loomcycle container**. In a container `localhost` is the container itself, not the host running Ollama — use the host's LAN address or a compose service name. loomcycle logs the endpoint it defaulted to at boot for exactly this reason.
- **Precedence:** explicit yaml `base_url` > `OLLAMA_BASE_URL` (the same var the chat provider reads, so one setting serves both) > `http://localhost:11434`. `OLLAMA_BASE_URL=disabled` is the chat side's opt-out marker and is treated as unset here.
- `provider: ollama` is the hosted ollama.com sibling: `OLLAMA_CLOUD_BASE_URL` + `OLLAMA_API_KEY`. Prefer `ollama-local` for a self-hosted box — the `-local` suffix is also how the consolidation dispatcher recognises a runtime on your own hardware and throttles fan-out accordingly.

**Any OpenAI-compatible server.** `provider: openai` with a `base_url` reaches vLLM, LocalAI, Text Embeddings Inference, Infinity, an Azure OpenAI deployment, or Ollama's own OpenAI-compat layer — no new driver needed:

```yaml
memory:
  embedder:
    provider: openai
    model: bge-m3
    base_url: http://vllm.internal:8000/v1
    api_key_env: MY_EMBED_KEY   # omit entirely for a keyless endpoint
```

`api_key_env` names an **environment variable**, never a key — the same convention the `providers:` map uses. When set it overrides the provider's default key source; naming a variable that is empty calls the endpoint unauthenticated (and logs that it did).

**Put the credential in `api_key_env`, never in `base_url`.** A transport failure surfaces the URL in the error text (Go wraps it in a `*url.Error`), and while a `user:password@host` password is redacted there, a token in the query string or in the username is not — it would reach logs verbatim.

**DeepSeek has no embeddings API.** It serves chat completions only, so there is no `deepseek` embedder and no amount of configuration produces one. A DeepSeek-for-chat deployment pairs with a local Ollama (above) or any OpenAI-compatible server for the embedding half. If DeepSeek ever ships an OpenAI-compatible embeddings endpoint, `provider: openai` + `base_url` reaches it with no code change.

**`dimensions` and the index ceiling.** Some models (qwen3-embedding) support Matryoshka truncation, and `dimensions:` asks for a narrower vector. Unset (0) keeps the model's native width. It is worth setting when that width exceeds **2000**: pgvector caps HNSW/IVFFlat indexes at 2000 dimensions, and while loomcycle creates no vector index today (sequential scan plus the scope pre-filter — see migration `0017`), a 4096-dim corpus forecloses ever adding one without re-embedding everything. Wide vectors otherwise store and search fine — a 4096-dim vector is 16 KB per row. **Changing `dimensions` on a populated store invalidates the stored vectors**: search compares the stored `dimension` column before the cosine query and refuses with `dimension_mismatch` until `POST /v1/_memory/reembed` migrates them. Only the `ollama-local` / `ollama` drivers send it today.

##### Consolidation similarity bands

The background consolidation pass decides whether a new fact duplicates an existing memory from cosine similarity. **Those thresholds are a property of your embedding model, not of the procedure**, so they are config:

```yaml
memory:
  consolidation:
    merge_threshold: 0.95     # >= this ⇒ the same fact, reworded — merge into the existing row
    related_threshold: 0.85   # this..merge ⇒ overlapping subject, different claim — add
```

Both default to the values shown, and both are validated at load as `0 < related_threshold < merge_threshold <= 1` (checked against the default when you set only one of the pair). **Do not carry these numbers between models** — measure them, with the command below.

The bundled consolidator reads the effective values at runtime from `Context op=capabilities` → `consolidation` — banding is arithmetic once the numbers are known, so it is done in code against the deployment's configured values rather than described to a model, and a band the deployment cannot report is treated as unknown and never fires (the pass adds a new row instead of rewriting a neighbour). The `{{memory:consolidation_bands}}` system-prompt placeholder remains available for any agent that reasons about duplicates in prose.

##### Calibrating the bands — `loomcycle memory-calibrate`

```bash
loomcycle memory-calibrate --config loomcycle.yaml
```

The command embeds a labelled corpus with **your** configured embedder, takes every probe-vs-base cosine, and reports where the three classes land:

| class | meaning | must |
|---|---|---|
| DUPLICATE | the same fact, reworded | land **above** `merge_threshold` |
| RELATED | same subject, a **different** claim | land **below** `merge_threshold` |
| UNRELATED | a different fact entirely | land below both |

It prints each class's `n / min / p05 / median / p95 / max`, the two class gaps, a threshold sweep (how many duplicates each candidate merges, and how many non-duplicates it would destroy), and a recommendation: `merge_threshold` at the midpoint of the safe window `(max(non-duplicate), min(duplicate)]`, `related_threshold` at the highest value that still recalls 75% of the related class.

| flag | effect |
|---|---|
| `--config <yaml>` | config to read the embedder + configured bands from (layered exactly as the server loads it: presets → `LOOMCYCLE_CONFIG_DIR` → `LOOMCYCLE_CONFIG_FILES` → `--config`). |
| `--dataset <file.json>` | your own corpus instead of the bundled one. Same shape as `internal/memory/eval/calibration.json` — copy it and swap in your facts. UNRELATED pairs are derived, never listed. |
| `--embedder auto\|stub` | `auto` (default) uses the configured embedder. `stub` is a **plumbing check only** — the deterministic stub embedder has no notion of meaning and will always report a model that cannot be calibrated. |
| `--check` | additionally fail when the **currently configured** bands are inert or destructive on the corpus. This is the regression guard against a silently dead setting. |
| `--output <file>` | write the JSON report (including every raw pair, so a run can be re-analysed without re-embedding). |

**Exit codes.** `0` — the duplicate and related classes separate, so a merge threshold exists. `1` — they overlap (no threshold both merges every duplicate and spares every distinct fact: either the model cannot tell these facts apart or a probe is mislabelled), or `--check` found the configured bands inert/destructive. `2` — invocation error.

**What it measured on `embeddinggemma`.** 768-dim, served by `ollama-local`; the bundled corpus of 12 facts and 24 labelled probes, reading raw cosine (`Memory op=search`'s `score` is raw cosine — an exact self-match returns 1.0):

| class | n | min | p05 | median | p95 | max |
|---|---|---|---|---|---|---|
| DUPLICATE | 12 | 0.7181 | 0.7909 | 0.9035 | 0.9376 | 0.9487 |
| RELATED | 12 | 0.3722 | 0.3884 | 0.5205 | 0.6129 | 0.6775 |
| UNRELATED | 72 | 0.1337 | 0.1460 | 0.2953 | 0.5018 | 0.5858 |

- DUPLICATE vs RELATED separate cleanly: gap **+0.0406**, safe window **(0.6775, 0.7181]**, midpoint **0.6978**.
- Merging at 0.68–0.70 catches **12/12** duplicates with **0** false merges out of the 84 non-duplicate pairs.
- A related band at 0.45 catches 9/12 related with 8/72 (11%) unrelated false positives; at 0.50, 7/12 and 4/72 (6%).

*(That `n=72` is the by-hand measurement, which read each probe's nearest neighbours out of a top-K search. The command scores the **full** pairwise matrix — 264 unrelated pairs for this corpus — so its UNRELATED row has a lower median and a max that can only be greater or equal. That direction is deliberate: `max(UNRELATED)` is what a merge threshold has to clear.)*

**The finding: the shipped default merges nothing on this model.** The highest genuine paraphrase scores **0.9487**, just under the 0.95 default. On the very model these docs recommend for self-hosting, merging never fires and duplicates accumulate forever — silently, with nothing logged.

**Why the defaults are still 0.95 / 0.85.** The risk is asymmetric. A threshold that is too **high** leaves duplicates lying around: untidy, and every one of them is still there to be merged later. A threshold that is too **low** merges two distinct facts into one, which is data loss with no undo. 0.95 fails safe, so it stays the default. The defect was never the value — it was that nothing told an operator their threshold was inert for their model. That is what the command fixes.

**The RELATED/UNRELATED overlap is a property of the model, not a tuning failure.** Their gap is **−0.2136**: the classes cross, and no `related_threshold` separates them. The command says so explicitly and recommends on the stated recall trade-off rather than presenting a clean split that does not exist. Expect false positives in the related band on this model, and treat the number as a dial, not a solution.

**These numbers describe `embeddinggemma` only.** Cosine scale is a property of the embedding model — a genuine paraphrase measured **0.7675** on this 768-dim model and **0.9005** on a 4096-dim `qwen3-embedding`. **Changing `memory.embedder.model` or `dimensions` invalidates your calibration: re-run the command.**

#### Failure modes

| Code | When | Recovery |
|---|---|---|
| `vector_unsupported` | Backend has no vector index (SQLite, or Postgres without `LOOMCYCLE_PGVECTOR_ENABLED`). | Install pgvector + set the env var, OR drop `embed: true` / `search` from the agent's flow. |
| `embedder_not_configured` | No `memory.embedder` in yaml. | Add the yaml block, or drop vector ops. |
| `embedder_not_implemented` | Operator picked `provider: anthropic` in v0.9.0. | Switch to `openai` or `gemini`; Voyage proxy ships v0.9.1. |
| `dimension_mismatch` | Stored rows are at one dim; query embedder runs at another (typical mid-migration). | Run `POST /v1/_memory/reembed` to update stored rows. |

`embed: true` failures *after* the k/v row landed (transient embedder network failure, etc.) do NOT roll back. The response carries `embedded: false` + `embed_warning: "..."` so the agent sees the partial outcome. Permanent configuration errors (no embedder, no vector support) refuse UPFRONT — the k/v row is not written.

#### Quota accounting

Embedding bytes do NOT count toward `memory_quota_bytes`. Operators don't pay for the vector's storage in their per-scope cap — only the k/v row's key + value bytes count (RFC §8).

#### Operator admin endpoints

```
GET  /v1/_memory/embed_stats?scope=
POST /v1/_memory/reembed?scope=&scope_id=&dry_run=true|false
```

**`embed_stats`** returns per-`(provider, model, dimension)` row counts + total embedding bytes for a scope. Operators run this BEFORE a model swap to estimate impact.

**`reembed`** walks rows whose stored `(provider, model)` doesn't match the configured embedder and re-embeds them via the live embedder. `dry_run=true` (the safe default — even without the flag) returns the plan plus sample keys; `dry_run=false` commits and returns `rows_reembedded` + `rows_failed` + `failed_keys`. Partial failures are non-fatal — operators see exactly which rows to retry.

The Web UI's `/ui/memory` page exposes both as a model-distribution badge + a "reembed plan" → confirm-and-commit flow.

#### Operator env vars (v0.9.0)

| Env var | Default | Purpose |
|---|---|---|
| `LOOMCYCLE_PGVECTOR_ENABLED` | `0` | Opt in to vector support on Postgres. Boot-time `pg_extension` probe; refuses to start when missing. |
| `LOOMCYCLE_SQLITE_VEC_PATH` | (unset) | **Reserved for v0.9.1.** Path to the sqlite-vec shared library. Currently parsed but unused; SQLite vector ops always refuse in v0.9.0. |
| `LOOMCYCLE_MEMORY_EMBED_BATCH_SIZE` | `100` | Default batch size for embedder calls. Provider hard caps still apply on top. |
| `LOOMCYCLE_MEMORY_EMBED_TIMEOUT_MS` | `30000` | Per-call embedder HTTP timeout. 0 = rely on outer ctx. |

#### Snapshot integration

Snapshots round-trip embeddings: `Capture()` packs the float32 vector as base64 little-endian + writes it under each memory row's optional `embedding` field. `Restore()` decodes + writes via `MemoryEmbedSet` on the destination. Destinations without vector support drop the embedding with a warning per row (k/v always lands; operators re-embed after enabling pgvector). The envelope shape is locked at v1.0 — Phase 1 readers (v0.8.x) emit `embedding: null` for every row; Phase 2 readers (v0.9.0+) populate it. No envelope migration is needed.

### What's NOT in v0.8.0 / v0.9.0

- Cross-tenant sharing (no `tenant` scope yet).
- Append-log primitive — agents wanting an event stream write `events/<timestamp>` keys + `list` with prefix.
- Automatic eviction / LRU — quota exceeded → the write fails with `quota_exceeded`. Agents call `delete` explicitly.
- Encryption-at-rest — disk encryption is operator-config-wide, not Memory-specific. Revisit alongside v0.9.x HA work.
- Server-side schema validation — values are JSON; agents own their schemas.
- v0.9.0 vector-specific deferrals: SQLite vector backend (v0.9.1), Anthropic native embedder (v0.9.1, via Voyage), HNSW index on `memory_embeddings` (v0.9.x perf pass — requires single-dim scope), per-agent embedder override (v0.10.x), hybrid search + rerankers (post-v1).

References: `internal/store/store.go` (interface), `internal/store/sqlite/sqlite.go` + `internal/store/postgres/postgres.go` (adapters), `internal/tools/builtin/memory.go` (tool), `internal/providers/embedder.go` (embedder substrate), `internal/api/http/memory_admin.go` (admin endpoints).

## The `Channel` tool — persistent inter-agent message bus (v0.8.4)

Channels are the asynchronous decoupled handoff primitive. One agent publishes JSON payloads to a named channel; another agent subscribes and drains them with cursor-based delivery. Same storage backbone as Memory (sqlite + postgres) plus an in-process notification bus for sub-millisecond same-process subscriber wake-ups.

### Operator setup

Channels MUST be declared in the top-level `channels:` block of `loomcycle.yaml`. No auto-creation; the namespace is operator-owned.

```yaml
channels:
  findings:
    scope: agent              # cursor isolation: agent | user | global
    default_ttl: 86400        # per-message TTL fallback (seconds)
    max_messages: 10000       # bounded storage; oldest trimmed first
    semantic: queue           # informational: queue | broadcast

  alerts:
    scope: global
    default_ttl: 3600
    max_messages: 1000
    semantic: broadcast
```

Per-agent ACL via the agent yaml:

```yaml
agents:
  researcher:
    channels:
      publish:   [findings]            # exact match
      subscribe: []                    # one-way producer
  analyst:
    channels:
      publish:   []
      subscribe: [findings, alerts, findings/*]  # trailing /* prefix wildcard
```

Wildcards are anchored at the end (`findings/*` matches `findings/alpha` but NOT `findings`). Mid-string globs are rejected at config-load so a typo can't accidentally grant `*` access.

### Tool surface (what an agent sees)

```jsonc
// Publish (producer side):
{ "op": "publish", "channel": "findings", "value": {...}, "ttl": 3600 }
//   → { "message_id": "msg_...", "channel": "findings", "dropped_oldest": 0 }

// Subscribe (consumer side) — polling, optional long-poll:
{ "op": "subscribe", "channel": "findings", "max_messages": 10, "wait_ms": 0 }
//   → { "messages": [{ "id", "value", "published_at" }], "next_cursor": "msg_...", "channel": "findings" }

// Ack — explicitly commit a cursor (only needed for at-least-once via peek+ack):
{ "op": "ack", "channel": "findings", "cursor": "msg_..." }

// Peek — non-consuming read (debugging or at-least-once consumer pattern):
{ "op": "peek", "channel": "findings", "from_cursor": "cur_0", "max_messages": 10 }

// List — informational, reports this agent's allowlists:
{ "op": "list_channels" }
//   → { "publish": [...], "subscribe": [...] }

// Await — fan-in barrier across MULTIPLE channels (wait for any / all /
// at_least N messages, or a timeout). Non-committing.
{ "op": "await", "channels": ["a", "b", "c"], "mode": "at_least", "n": 3, "wait_ms": 30000 }
//   → { "satisfied": true, "timed_out": false, "mode": "at_least",
//       "fired": ["a","b","c"], "total_messages": 3,
//       "results": { "a": { "messages": [...], "next_cursor": "msg_..." }, ... } }

// Broadcast — symmetric fan-OUT: publish one payload to MULTIPLE channels.
{ "op": "broadcast", "channels": ["a", "b", "c"], "value": {...}, "ttl": 3600 }
//   → { "published": 3, "failed": 0,
//       "results": [{ "channel": "a", "message_id": "msg_...", "dropped_oldest": 0 }, ... ] }
```

### Fan-out (`broadcast`)

`broadcast` is the producer-side bookend to `await`: it publishes the **same** payload to a set of channels in one call — e.g. an orchestrator pings N worker channels to start, then `await`s their result channels. It takes the same per-message fields as `publish` (`value`, `ttl`, `deliver_at`), applied to every named channel.

It is **atomic at the ACL pre-flight**: every channel is resolved against the agent's **publish** allowlist (and the `_system/` / `publisher: system` refusals) *before any write*, so one denied channel refuses the whole op — there is no partial broadcast. A per-channel storage error after that (rare) is reported in that channel's `results` entry while the successful publishes stand; the op itself doesn't error. Channels are de-duplicated; max 32 per call.

### Fan-in barrier (`await`)

`subscribe` reads ONE channel. When a consumer must wait for **several** producers — e.g. a consolidator that fires after "N collectors finish, or a timeout" — `await` is the first-class combinator:

- **`mode: any`** — satisfied when ≥1 named channel has a message.
- **`mode: all`** — satisfied when every named channel has ≥1.
- **`mode: at_least` + `n`** — satisfied when the total message count across all named channels reaches `n`.

It long-polls up to `wait_ms` (clamped to the operator's `LOOMCYCLE_CHANNELS_LONGPOLL_CAP_MS`), waking the instant a producer publishes. A timeout is **not** an error: it returns `{ "satisfied": false, "timed_out": true }` with whatever partials accumulated — the caller decides what to do.

**`await` is non-committing (detection only).** It returns each fired channel's `next_cursor` but never advances the committed cursor — auto-committing across N channels on a partial/`any`/timeout result would silently consume messages on channels the agent didn't act on. After `await` returns, the agent `subscribe`/`ack`s exactly what it processes. Each channel is resolved through the agent's **subscribe** allowlist, so `await` can't read a channel it couldn't `subscribe` to. Max 32 channels per call.

> **`await` vs `Agent.parallel_spawn`:** both are barriers, but over different things. `parallel_spawn` joins the **sub-agents this agent spawned** (`wg.Wait()`). `await` joins **independent producers** — scheduler-fired runs, inbound webhooks, separately-spawned agents — that `parallel_spawn` can't reach. A scheduler-driven fan-out (N collectors) → consolidator pipeline uses `await`; an in-agent fan-out uses `parallel_spawn`. (`on_complete: channel.publish` on a `ScheduledRun` stamps `schedule_name` per fire — the distinct-producer key an `at_least`/`all` consolidator counts.)

### Delivery semantics

**`subscribe` is at-most-once-by-default**: the tool commits `next_cursor` BEFORE returning. Agents that just loop `subscribe` march forward without tracking cursors themselves. If the loomcycle process or the agent crashes between "subscribe returned" and "agent finished processing," the batch is lost.

**For at-least-once / crash safety, use `peek` + `ack`**:

1. `peek` — read messages without advancing the cursor.
2. Process them; persist the work somewhere durable.
3. `ack` with the cursor of the last-processed message — commits the cursor.
4. Next `peek` reads from the new committed cursor.

Cursor monotonicity is enforced: `ack` with a cursor older than the currently committed one returns `cursor_regression`. Cursors are opaque strings (ULID-shaped under the hood — sortable by publish time, agents should not parse them).

### Cursor scope (mirrors Memory)

- **`scope: agent`** — cursor is per agent name. Two researcher-agent runs share a cursor on the same channel (work continues across runs). Two DIFFERENT agents subscribing to the same agent-scoped channel each maintain their own queue.
- **`scope: user`** — cursor is per `user_id`. Two runs for the same user share a cursor — the "user-stream" shape. Cross-agent cursor sharing for the same user (the canonical "researcher → analyst" hand-off pattern).
- **`scope: global`** — one shared cursor for the whole channel. Cross-tenant fan-out broadcasts. Operator declares the channel explicitly; an unintentional `global` ACL can leak across tenants.

### Long-poll subscribe

`subscribe` accepts an optional `wait_ms`. If the storage read returns empty, the tool blocks on the in-process notification bus for that channel until either a new publish lands or the timeout fires. Cap is operator-controlled (`LOOMCYCLE_CHANNELS_LONGPOLL_CAP_MS`, default 30 s). Same-process subscribers wake within microseconds of a publish; cross-process subscribers (multi-replica deployments) fall back to polling — that's deferred to v0.9.x.

### Diagnostic logging (v0.12.7)

`LOOMCYCLE_CHANNEL_DEBUG=1` enables structured per-publish + per-retry log lines used to characterise the residual subscribe-empty race the v0.8.x bounded-retry workaround mitigates. Default off — these lines are noisy on a busy install. Operators flip the flag during a load test, capture a window of logs (~20 retry-saved lines), then flip it back. The two log shapes are:

- `channel "X" publish: id=... commit_us=N pool_total=A pool_acquired=B pool_idle=C` — fires on every successful publish. `commit_us` measures the postgres `tx.Commit` duration in microseconds; the pool fields capture pgxpool state at commit time.

- `channel "X" subscribe-race-recovered attempt=N msgs=K recovery_lag_ms=Y first_read_lag_us=Z from_cursor="..." pool_total=A pool_acquired=B pool_idle=C` — fires when the bounded retry rescued a subscribe after Bus.Wait returned via the waker but the immediate re-read found no rows. `first_read_lag_us` is the diagnostic field that distinguishes the race window (waker → empty read), separate from `recovery_lag_ms` (waker → eventually-non-empty read).

A complementary `subscribe-race-exhausted` log fires when all 3 retry attempts return empty. SQLite's parity logging (commit_us only — single-writer, no pool) lets operators A/B the same workload against both backends. See `~/work/loomcycle-internal/doc-internal/channel-race-investigation.md` for the hypothesis table.

### Quotas and overflow

- Per-write payload cap: `LOOMCYCLE_CHANNELS_MAX_VALUE_BYTES` (default 64 KB). 0 disables.
- Per-channel storage cap: `max_messages` in the operator yaml. When a publish would push the count past this, the OLDEST rows are trimmed inside the same txn. Publisher never blocks. The publish result includes `dropped_oldest: N`.
- TTL reaper: periodic background goroutine deletes expired rows. Cadence `LOOMCYCLE_CHANNELS_SWEEP_MS` (default 15 min). Read paths filter expired rows at WHERE regardless of sweeper cadence.

### Sub-agent inheritance

A spawned sub-agent inherits the parent's Channel ACL via ctx (mirror of `WithMemoryPolicy` / `WithHostPolicy`). A sub-agent cannot publish to a channel the parent couldn't. This is what makes the ACL story enforceable: no escalation across the `Agent` boundary.

### Typed audit events

Every successful `publish` and every delivered message surfaces a structured event on the run's SSE stream — distinct from the surrounding `tool_call` / `tool_result` envelope so SSE consumers can build channel-activity dashboards by filtering on event Type:

| Event type | Fires on | Payload |
|---|---|---|
| `channel_publish` | Successful Channel.publish | `channel`, `message_id`, `scope`, `scope_id`, `payload_bytes`, `payload_preview` (truncated at 200 chars), `dropped_oldest` (overflow trim count) |
| `channel_delivery` | One per message in a subscribe response (incl. replay batches) | `channel`, `message_id`, `scope`, `scope_id`, `payload_bytes`, `payload_preview`, `cursor` (= the message's own id) |

Publish events count **production** (one per successful `Channel.publish`). Delivery events count **consumption** (one per message returned to a subscriber — on a `cur_0` replay, every message in the batch fires a fresh delivery event). All the same information is available in the `tool_result` envelope; the typed events exist purely to avoid the need to parse every `tool_result` JSON to detect channel activity.

The payload preview is capped at 200 UTF-8 characters with a trailing `…` when truncated; adapters that need the full payload read it from the `tool_result` envelope (which carries the untruncated JSON). `payload_bytes` is always the full untruncated byte length.

### Out of scope for v0.8.4

- Cross-process / multi-replica notification — multi-replica subscribers fall back to polling. v0.9.x.
- Dead-letter queue / redelivery-count tracking.
- Streaming subscribe (tool emits events mid-call). Polling only; long-poll covers the real-time-ish use case.
- Schema enforcement on payloads — operator declares JSON schema per channel.
- Admin API for inspecting / purging channels. Manual SQL for v0.8.4.

References: `internal/store/store.go` (interface), `internal/tools/builtin/channel.go` (tool), `internal/channels/bus.go` (notification bus), `doc-internal/rfcs/channels-tool.md` (full design RFC, gitignored).

## LocalAPI tools — OpenAPI gateway (scaffolded; not the v0.4 integration vehicle)

Operators register a local HTTP API by pointing at an OpenAPI spec in YAML:

```yaml
local_api:
  spec: openapi.yaml          # relative to this YAML's directory
  base_url: http://localhost:3000
  tool_name_prefix: jobs       # tools become jobs__<operationId>
```

At config-load, loomcycle parses the spec and registers one tool per operation. The configured prefix determines the tool names (e.g. `jobs__listProjects`, `jobs__createApplication`). Each tool's input schema is derived from the OpenAPI parameters + request body schema. Agents call them like any other tool; loomcycle forwards the request to `base_url`.

**Status (v0.4.0).** Code, parser, dispatcher wiring, and unit tests are landed (`internal/tools/localapi/`). The runtime registers LocalAPI tools at startup when `cfg.LocalAPI.SpecPath` is non-empty. The first production consumer (jobs-search-agent) chose the MCP-server pattern instead — it runs its own `/api/mcp` Streamable-HTTP server exposing typed tools (e.g., `mcp__jobs__getAgentContext`, `mcp__jobs__patchApplication`), which loomcycle consumes through the existing MCP HTTP transport. LocalAPI stays available for future consumers that prefer "wire an OpenAPI spec, get typed tools" without standing up an MCP server.

**Why this matters when it lands.** Today an agent prompt has to spell out `GET http://localhost:3000/api/agent/context` as a string the model writes. The model occasionally invents wrong hostnames or paths (the cv-batch-adapter cv-adapter children burned all their iterations guessing hostnames in May 2026). With LocalAPI: the model sees a typed tool `jobs__getAgentContext` with parameter docs, and the URL string is loomcycle's responsibility, not the model's.

**Allowlist behaviour.** LocalAPI tools are subject to the same default-deny: agents must list them in `tools` (glob `jobs__*` works). The HTTP destination (`base_url`) is NOT subject to `LOOMCYCLE_HTTP_HOST_ALLOWLIST` — operator opting into a `local_api` entry is the explicit grant.

**No SSRF defense for `base_url`.** LocalAPI is for trusted internal APIs only. If you need allowlisted external HTTP access from agents, use the `HTTP` / `WebFetch` built-ins, which carry the full SSRF protection.

References: `internal/tools/localapi/`, `cmd/loomcycle/main.go` (registration), `loomcycle.example.yaml` (commented `local_api:` example).

## Provider × tool matrix

What works with what (Gemini added in v0.7.2 alongside the existing four backends):

|              | Anthropic | OpenAI | DeepSeek | Gemini | Ollama (tool-tuned) |
|---|:---:|:---:|:---:|:---:|:---:|
| `Read` / `Write` / `Edit`  | ✅ | ✅ | ✅ | ✅ | ✅ |
| `HTTP` / `WebFetch`        | ✅ | ✅ | ✅ | ✅ | ✅ |
| `WebSearch`                | ✅ | ✅ | ✅ | ✅ | ✅ |
| `Bash`                     | ✅ | ✅ | ✅ | ✅ | ✅ |
| `Agent` (sub-agents)       | ✅ | ✅ | ✅ | ✅ | ✅ |
| `Skill` (on-demand)        | ✅ | ✅ | ✅ | ✅ | ✅ |
| `LocalAPI` (OpenAPI gateway) | ⏳ | ⏳ | ⏳ | ⏳ | ⏳ | (scaffolded — for future OpenAPI-without-MCP-server consumers) |
| MCP tools (stdio + HTTP)   | ✅ | ✅ | ✅ | ✅ | ✅ |
| Native cache_control       | ✅ | ❌ | ❌ | ❌ | ❌ |
| Parallel tool calls        | ✅ | ✅ | ✅ | ✅ | depends on model |
| Streaming text + tool_use  | ✅ | ✅ | ✅ | ✅ | ✅ |
| `Usage.Model` populated    | ✅ | ✅ (v0.6.0+) | ✅ | ✅ | ✅ |
| Effort hint translation    | ✅ thinking.budget_tokens | ✅ reasoning_effort | ✅ (via OpenAI wrapper) | ✅ thinkingConfig.thinkingBudget | ❌ |

Ollama caveat: tool calling only works on **tool-tuned** models (qwen3+, Llama 3.1 instruct variants, Qwen2.5-Instruct, Mistral Nemo Instruct). Non-tool-tuned models will silently drop the `tools` field instead of calling them — Ollama's behaviour, not the driver's. Tool_use IDs are synthesized by the loop because Ollama doesn't issue them.

DeepSeek caveat: the v0.6.0 driver wraps the OpenAI driver, so it inherits OpenAI's behaviour exactly. The `deepseek-reasoner` model emits `reasoning_content` separately from `content`; the driver surfaces it as `EventDone.Reasoning` (since v0.7.0) and now also as live `EventThinking` events (since v0.7.1).

Gemini caveat: enabled by `GEMINI_API_KEY` env (Google AI Studio key). Optional `GEMINI_BASE_URL` overrides for Vertex AI deployments. Gemini reads the model name from the URL path (not the request body), uses `x-goog-api-key` header auth, and emits `functionCall` parts instead of OpenAI-style `tool_calls`. The driver translates loomcycle roles (`user` / `assistant`) to Gemini's (`user` / `model`). Tool_use IDs are synthesized by the loop because Gemini doesn't issue them. Effort hint translates to `generationConfig.thinkingConfig.thinkingBudget` on gemini-2.5-flash / gemini-2.5-pro: `low` → 0 (disable), `medium` → 2048, `high` → 8192 (clamped to `max_tokens - 1024` when budget would equal/exceed `max_tokens`). Thinking *output* is opaque (Gemini emits a `thoughtSignature` blob, not text) — no `EventThinking` plumbing yet; will revisit when Gemini opens up a text-trace surface.

## Tool-use hooks (v0.7.x+)

Operator-supplied middleware around tool dispatch. External apps register HTTP-webhook callbacks against `(agent, tool, phase)` selectors; loomcycle invokes them around the dispatcher so the hook can rewrite the input, short-circuit with a synthetic result, or rewrite the post-tool result.

The canonical use case is **wrapping untrusted content** from `WebFetch` / `HTTP` / MCP results in trust-boundary markers so a downstream LLM treats payloads as data rather than instructions. Other shapes the seam supports: per-tool quotas, audit logs, content sanitisation, soft-deny patterns ("you tried to fetch X; here's a redacted version instead"), OTEL spans tied to tool invocations.

### Registration

```
POST /v1/hooks
{
  "owner": "jobs-search-web",       // app UID; (owner, name) is the identity
  "name":  "scan-webfetch",
  "phase": "post",                  // "pre" | "post"
  "agents": ["*"],                  // glob list; empty = ["*"]
  "tools":  ["WebFetch", "HTTP"],   // glob list; empty = ["*"]
  "callback_url": "https://jobs-search-web.local/api/hooks/scan",
  "fail_mode": "open",              // "open" (default) | "closed"
  "timeout_ms": 5000                // default 5000, ceiling 60000
}
→ 200 { "id": "hook_xxx" }

GET    /v1/hooks               // debug listing
DELETE /v1/hooks/{id}          // remove a registration
```

**Idempotency**: re-registering the same `(owner, name)` **replaces** the prior entry in-place (preserves chain ordering, mints a fresh ID). Solves the cascading-on-restart problem cleanly: an app that re-registers on its own startup never accumulates duplicates.

**No persistence** across loomcycle restart. Apps re-register on their own startup. If your app is down, your hooks aren't active — which matches reality (the app can't process callbacks anyway).

### Filtering

- `agents`: array of exact matches or `["*"]`. Empty / missing = `["*"]`.
- `tools`: array of exact matches; supports `prefix*` glob (`mcp__jobs__*`). Empty / missing = `["*"]`.
- A hook fires when **both** match. Multiple hooks can match the same call — they chain in registration order (Pre) or reverse order (Post, LIFO middleware).

### Webhook payload

**Pre** (`POST <callback_url>`):
```json
{
  "phase": "pre", "owner": "...", "hook_name": "...",
  "agent": "company-researcher", "user_id": "...", "agent_id": "...",
  "tool_call": { "id": "...", "name": "WebFetch", "input": { "url": "..." } }
}
→ { "input": {...} }                                 // rewrite
→ { "deny": { "is_error": false, "text": "..." } }   // short-circuit
→ {} or 204                                          // no change
```

**Post** (`POST <callback_url>`):
```json
{
  "phase": "post", "owner": "...", "hook_name": "...",
  "agent": "company-researcher", "user_id": "...", "agent_id": "...",
  "tool_call": { "id": "...", "name": "WebFetch", "input": {...} },
  "tool_result": { "is_error": false, "text": "...page body..." }
}
→ { "result": { "is_error": false, "text": "<untrusted>...</untrusted>" } }
→ {} or 204                                          // no change
```

### Composition

- **Pre chain**: registration order. The first non-nil `deny` short-circuits the rest of the Pre chain.
- **Post chain**: reverse registration order (LIFO middleware). Each hook sees the result the prior (inner) hook produced.
- Hooks within the chain run sequentially per-tool. The **parallel tool dispatcher** (v0.7.x) runs N tool calls in parallel; their hook chains fire in parallel too. The webhook server must handle concurrent calls.

### Failure modes

- **`fail_mode: "open"`** *(default)*: webhook timeout / 5xx / network error → original input or result passes through unchanged. Right for telemetry-shaped hooks where the hook should never block tool dispatch when the registering app is down.
- **`fail_mode: "closed"`**: webhook timeout / 5xx / network error → tool fails with `IsError: true`. Right for security-shaped hooks (injection scanners) where a down hook would let bypassed payloads through.

### Trust-boundary invariants

- Hooks run **after** the policy layer (`tools` / `allowed_hosts`). Hooks may narrow further or rewrite content. The **only** exception is the per-call host-widening capability below, which requires explicit operator opt-in.
- Hooks **cannot** tear down the agent run. The worst they can do is short-circuit one tool call with a synthetic `IsError: true` result.
- Webhook payloads include `agent_id` and `user_id` for correlation but **do NOT** include the agent's prompt or message history.

### Per-call host widening (v0.8.17)

A Pre-hook can grant the model permission to fetch a hostname that's *not* on the operator's static `LOOMCYCLE_HTTP_HOST_ALLOWLIST` and *not* on the runtime caller's `runRequest.allowed_hosts`. The grant is **per-tool-call** — scoped to exactly one `Execute()`, never cached server-side, never inherited by sub-agents.

This solves the dynamic-discovery problem: `job-searcher` finds a job posting on a site nobody pre-enumerated; the calling service's hook checks its own per-user allowlist + reputation oracle + business rules and approves the URL on the spot.

#### Opt-in (operator yaml only)

```yaml
hooks:
  permit_host_widen:
    # entries are "[tenant:]owner": bare = the shared tenant ""; "tenant:owner" = that tenant only.
    owners: ["jobember:jobs-search-web"]
```

Or env: `LOOMCYCLE_HOOKS_PERMIT_HOST_WIDEN_OWNERS=jobember:jobs-search-web` (appends to yaml).

- **Exact match on the `(tenant, owner)` pair**, where `tenant` is the hook's authoritative `Hook.Tenant` (`""` for an operator/global hook or a single-tenant deployment) and `owner` is its caller-supplied `Hook.Owner`. No globs. A bare entry omits the `tenant:` prefix and binds to `""`.
- **Why the tenant, not just the owner:** `Hook.Owner` is caller-supplied; only the tenant is authoritative (stamped on register). Keying on owner alone would let a *second* tenant register a hook with a permitted owner string and widen hosts for its own runs — escaping the operator floor. `(tenant, owner)` confines a grant to the tenant the operator named. (RFC AF follow-up.)
- Without a matching entry, ANY hook's `allow_hosts` field is silently dropped at the dispatcher (with a `hooks: pre ... dropping grant` WARN log + counter increment via `Dispatcher.Stats().HostWidenDenied`). The model and the runtime caller cannot enable this — only the operator yaml can.
- **Caller-authoritative mode interaction**: when `LOOMCYCLE_HTTP_CALLER_AUTHORITATIVE=true`, a permitted hook STILL widens on top of the caller's authoritative list. Operator yaml is the floor for *behaviors* (including hook delegation), not just for the static host list.

#### Wire shape

A Pre-hook returns an optional `allow_hosts` field alongside the existing `input` / `deny`:

```json
{
  "input": {...},
  "deny": {"is_error": true, "text": "..."},
  "allow_hosts": ["acme.com", ".trusted-cdn.com"]
}
```

#### Matching semantics

Intentionally **stricter** than the operator allowlist:

- `"acme.com"` (no leading dot) → **exact hostname match only**. Won't widen `careers.acme.com`.
- `".acme.com"` (leading dot) → **suffix-match** (matches `acme.com` AND any subdomain). Symmetric with the operator-list shape.

This lets cautious hook authors be surgical ("approve only the literal URL the model asked about") while supporting the broader case via the leading-dot opt-in.

#### Composition

- Multiple permitted-owner hooks contributing `allow_hosts` in one chain → the dispatcher returns the **deduplicated UNION** (case-insensitive on hostname).
- A `deny` anywhere in the chain **discards all prior** `allow_hosts` grants (we don't carry policy widenings into a denied call — confused-deputy hardening).
- Order of `AllowHosts` in the outcome is first-seen across the Pre chain.

#### Audit event

The loop emits one `EventHostWidened` per dispatched call where widening fired:

```json
{
  "type": "host_widened",
  "host_widening": {
    "tool_call_id": "lc-0-0",
    "tool_name": "WebFetch",
    "url": "https://acme.com/careers/123",
    "hook_owner": "jobs-search-web",
    "hook_name": "url-gate",
    "hosts_added": ["acme.com"]
  }
}
```

Persisted to the `events` table via `makeRecordingEmit` like every other typed event. Operators audit confused-deputy patterns by joining on `tool_call_id` and comparing the requested URL's host to the granted `HostsAdded` — if they're always identical for one owner, the hook is probably echoing model input without independent validation.

Aggregate counters via `Dispatcher.Stats()`:

```go
type DispatcherStats struct {
    HostWidenPermitted int64 // grants honoured
    HostWidenDenied    int64 // grants dropped (owner not in permit list)
}
```

#### SECURITY — confused-deputy hazard

The Pre-hook's `tool_call.input` is **model-generated and untrusted**. A naive hook that echoes `input.url`'s hostname back as `allow_hosts` literally lets the model widen its own allowlist. **Validate independently** — against the user's own preferences, a per-tenant allowlist, a domain-reputation service, your own business rules. Never trust the URL the model is asking about as authority for whether the URL should be approved.

#### Known limitations (v1)

1. **Redirect to a new unknown host within a single fetch** is NOT re-validated by the hook. The extras attached pre-dispatch cover the entire tool call (initial URL + all redirects), so a redirect to a hostname the hook *did* approve up-front works. But a redirect to a brand-new host nobody approved fails. Practical workaround: hooks for redirect-heavy use cases (job boards proxied through ATS trackers) should approve the likely redirect targets too.
2. **Dial-time private-IP block is NOT bypassable.** Even if `allow_hosts` includes `localhost`, the host's resolved private IP is still blocked unless the operator separately opted in via `HTTPPrivateHostAllowlist`. This is a separate, orthogonal trust boundary — hooks widen at the hostname layer, never at the IP layer.
3. **Sub-agents do NOT inherit a parent's widening.** Per-tool-call scope means the grant evaporates the moment Execute() returns. If a sub-agent needs the same widening, the same hook needs to be registered against the sub-agent's name (via the hook's `agents:` glob).
4. **No server-side caching.** Every WebFetch to a hostname outside the static list triggers a fresh callback. Hooks should cache client-side if their decision is stable for the user/tenant.

References: `internal/hooks/`, `internal/api/http/hooks.go` (registration routes), `internal/loop/loop.go::dispatchOneTool` (chain invocation), `internal/config/config.go::HooksConfig` (operator opt-in shape), `internal/tools/builtin/httptool.go::hostAllowedExtras` (matcher).

## The `Interruption` tool — human-in-the-loop primitive (v0.8.16)

The human bridge in the v0.8.x substrate. Three ops:

```
Interruption.ask(question, options?, context?, timeout_ms?, priority?)
  → blocks the loop; tool result carries the human's answer
Interruption.notify(message, priority?)
  → fire-and-forget message
Interruption.cancel(interruption_id)
  → agent unblocks a previously-asked question it answered itself
```

### Why "Interruption" not "Question"?

Generalises the previously-planned Question tool with a broader option set — v0.8.16 only writes `kind: question`, but the schema's `kind` column + the `_system/interrupts/*` channel namespace are forward-compatible for future `pause` / `wait_until` / `approval` kinds without reopening the design.

### Three delivery surfaces

Operator picks one via `interruption.backend:`:

| Backend | Where the human sees it | When to pick it |
|---|---|---|
| `webui` (default) | `/ui/interrupts` inbox in the embedded Web UI | Production. The cookie-authed session matches the run's `user_id`. |
| `mcp_server:<name>` | Consumer's own MCP server tool (`mcp__<name>__ask`) | When the consumer already runs an MCP server (e.g. jobs-search-agent's `/api/mcp`) and wants to integrate question rendering into its own UI. |
| `cli` | Local-dev stdin/stdout (operator runs a separate `loomcycle-interrupt-cli`-style script that subscribes to `_system/interrupts/pending` and posts the resolve endpoint) | Local development. |

The agent-facing tool surface is identical across all three; only the resolve path differs.

### Per-agent ACL

```yaml
agents:
  batch-processor:
    tools: [Read, Memory, Interruption, Context]
    interruption:
      enabled: true
      kinds: [question]      # v0.8.16 only value; future: "pause", "approval"
      max_pending: 1         # per-run cap; 0 = use operator default
```

Default-deny: missing the `interruption` block means every op returns `is_error` with a clear "not enabled" refusal. Same shape as `memory_scopes` / `agent_def_scopes` / `evaluation_scopes`.

### Operator config

```yaml
interruption:
  backend: webui                     # webui | mcp_server:<name> | cli
  default_timeout_ms: 3600000        # 1h — when an `ask` doesn't pass timeout_ms
  max_timeout_ms: 86400000           # 24h — hard ceiling
  max_pending_per_run: 10            # operator global cap; agent yaml narrows
  heartbeat_interval_ms: 30000       # how often the during-block heartbeat ticks
```

The during-block heartbeat is load-bearing — without it, a question that waits an hour gets reaped by the v0.5.0 sweeper (default `StaleAfter` 10 min, calibrated for tool-dispatch durations). The Interruption tool spawns a dedicated ticker that fires `Store.UpdateHeartbeat` directly while blocked.

### System channels

The signal flow rides on the v0.8.6 `_system/*` namespace — operator declares two channels in the top-level `channels:` block:

```yaml
channels:
  _system/interrupts/pending:
    scope: user
    semantic: broadcast
    publisher: system
    default_ttl: 3600
  _system/interrupts/resolved:
    scope: user
    semantic: broadcast
    publisher: system
    default_ttl: 86400
```

The Web UI inbox subscribes to `_system/interrupts/pending` via the existing Channel subscribe path. The resolve endpoint publishes to `_system/interrupts/resolved` so external dashboards / Slack bots can render the audit trail.

### Wire surface

| Endpoint | Purpose |
|---|---|
| `POST /v1/runs/{run_id}/interrupts/{interrupt_id}/resolve` | Submit an answer. Validates options (422), expiry (410), already-terminal (409). |
| `GET /v1/runs/{run_id}/interrupts?status=<pending\|resolved\|...>` | List per-run interrupts (audit trail). |
| `GET /v1/users/{user_id}/interrupts?status=pending` | User-scoped inbox (drives the Web UI). |
| `mcp__loomcycle__interruption_resolve` | 21st LoomCycle MCP meta-tool. Lets an external orchestrator (Claude Code) act as the answerer for any pending interrupt. |

### Storage

New `interrupts` table (migration 0011). Columns: `interrupt_id`, `run_id`, `kind` (closed enum), `status` (`pending`/`resolved`/`timed_out`/`cancelled`), `question`, `options` (JSON array), `context_data`, `priority`, `answer`, `answer_meta` (JSON; future-kind discriminated), timestamps, `resolved_by`, and denormalised `user_id` / `agent_id` / `agent_name` for the listing queries.

### SSE event

`EventInterruptionPending` (type `interruption_pending`) on the run's SSE stream with `interruption: {interrupt_id, kind, question, options, context, priority, expires_at}`. Web UI consumers use this for real-time modal rendering without a follow-up fetch.

### Architecture references

- `doc-internal/rfcs/interruption-tool.md` — RFC bundling concept + 5-PR implementation plan
- `internal/tools/builtin/interruption.go` — tool implementation (Bus.Wait blocking + heartbeat ticker + ACL gate)
- `internal/api/http/server.go::handleResolveInterrupt` — resolve endpoint
- `internal/api/mcp/handlers.go::handleInterruptionResolve` — 21st MCP meta-tool

