# Configuration — provider routing, tiers, and agent files

This guide is for operators wiring loomcycle for the first time, or developers tuning individual agents. It answers: **given my setup, what yaml do I write?**

Scope: the **model-routing axis** of `loomcycle.yaml` — providers, tier candidate lists, the `models:` alias map, the `user_tiers:` overlay — plus the agent `.md` frontmatter fields that control per-agent routing. MCP-server configuration lives in [`docs/MCP_INTEGRATION.md`](MCP_INTEGRATION.md); storage in [`docs/POSTGRES.md`](POSTGRES.md); concurrency/cache/hooks are not covered here.

---

## 0. Why this doc exists

Loomcycle's resolver runs **four nested decision layers** to pick the `(provider, model)` for any given agent run:

```
            ┌───────────────────────────────────────────┐
            │  4. Explicit pin on the agent             │  (highest)
            │     agent.provider + agent.model          │
            └─────────────────┬─────────────────────────┘
                              │ falls through if no pin
            ┌─────────────────▼─────────────────────────┐
            │  3. Per-agent override                    │
            │     agent.providers / agent.models[tier]  │
            └─────────────────┬─────────────────────────┘
                              │ falls through if no override
            ┌─────────────────▼─────────────────────────┐
            │  2. user_tier overlay                     │
            │     user_tiers.<tier>.provider_priority   │
            │     user_tiers.<tier>.tiers[agent.tier]   │
            └─────────────────┬─────────────────────────┘
                              │ falls through if user_tier
                              │ not set or has no tiers map
            ┌─────────────────▼─────────────────────────┐
            │  1. Library defaults                      │  (lowest)
            │     provider_priority / tiers             │
            └───────────────────────────────────────────┘
```

The doc's job is to make you confident about **which layer to touch when**. Most operators only need layer 1 (library defaults) and stop there. As your deployment grows — multi-tenant, multi-plan, mixed-provider — you climb the layers.

**One sentence**: configure routing top-down for the common case (library defaults), then push specific exceptions UP the precedence stack (user_tier overlay for plan-tier policy, per-agent override for "this agent is special," explicit pin for unambiguous "exactly this model").

The doc presents this as **four cookbook patterns**. Find the pattern matching your setup, copy the yaml, adjust to your providers.

---

## 1. The four resolution axes (overview)

| Axis | Yaml field | Purpose | When you touch it |
|---|---|---|---|
| **Library `provider_priority`** | top-level `provider_priority:` | The walk order across providers when nothing else overrides | Always — every config sets this |
| **Library `tiers`** | top-level `tiers:` | Per-task-tier (`low`/`middle`/`high`) ordered candidate lists | Always — every agent that uses `tier:` reads this |
| **`models:` alias map** | top-level `models:` | Aliases like `sonnet → {anthropic, claude-sonnet-4-6}` so agents can say `model: sonnet` | When you want short names in agent .md files instead of full model IDs |
| **`user_tiers:` overlay** | top-level `user_tiers:` | Per-user-class policy (free/low/medium/high). Restricts which providers + models a user's runs may touch | When your app has multiple plan tiers OR multi-tenant cost/privacy boundaries |
| **Per-agent `providers:`** | agent .md `providers:` | Replaces the priority order for THIS agent only | When one agent must skip certain providers (privacy, capability) |
| **Per-agent `models[tier]:`** | agent .md `models:` | Replaces the tier candidate list for THIS agent | When one agent needs specific models in its tier slot |
| **Explicit pin** | agent .md `provider:` + `model:` | Hard-pin to exactly one `(provider, model)` | When the agent has a sensitive-paths reason to never fall through |

The resolver walks **top-down** through this table on every request. The first axis that has something to say wins. See § 2 for the exact decision tree.

---

## 2. Resolution precedence — decision tree

The resolver lives in `internal/resolve/matrix.go:281` (`Resolve(req AgentRequest) (Decision, error)`). The precedence is:

```
Given:  AgentRequest{ Name, Tier, PinProvider, PinModel, Providers, Models, UserTier }

   1. PinProvider AND PinModel both set?
      → resolvePin()       (matrix.go:293)
        - Looks up the matrix to confirm (provider, model) is reachable.
        - Returns Decision{provider, model} or ErrPinUnavailable.

   2. Only ONE of (PinProvider, PinModel) set?
      → ErrInvalidArgument (half-pin is config error, caught at load too)

   3. Tier required from here on. If Tier == "":
      → ErrInvalidArgument

   4. Build the candidate list:
      a. agent.Models[tier] set?  use it. (per-agent override; full replacement)
      b. user_tier.Tiers[tier] set?  use it. (overlay)
      c. library tiers[tier] set?  use it.
      d. otherwise →                ErrTierUnavailable

   5. Build the provider walk order:
      a. agent.Providers AND user_tier.ProviderPriority both set?
         → intersection in agent-order (matrix.go:440)
         → empty intersection → ErrTierAgentNotAvailable  (policy refusal)
      b. only agent.Providers set?               → agent.Providers
      c. only user_tier.ProviderPriority set?    → user_tier order
      d. neither set?                            → library provider_priority

   6. Walk the candidate list, skipping any pair whose:
      - provider is excluded (no API key) OR
      - provider is unreachable (probe failed) OR
      - model is stalled (recent driver error)
      - model is not listed by the provider's /v1/models (or equivalent)

   7. First survivor →  Decision{provider, model, effort, ...}
      No survivor    →  ErrTierUnavailable
```

Two error classes worth remembering, because they have different operator semantics:

| Error | When | Caller should |
|---|---|---|
| `ErrTierUnavailable` | Matrix-side problem — every candidate stalled / unreachable | Retry with backoff; surface as 503 |
| `ErrTierAgentNotAvailable` | **Policy-side** — agent's `providers:` and user_tier's `provider_priority` have no overlap | NOT retry — return 403/"upgrade your plan"; the user genuinely doesn't have access |

This distinction matters because a transient outage and "your plan doesn't allow this agent" are operationally different. Loomcycle separates them at the resolver layer so the app server can map them to different HTTP responses.

### Mutual exclusion at config-load

`internal/config/config.go:1985` enforces **pin XOR tier** at config-load time:

```go
hasPin := agent.Provider != "" || agent.Model != ""
hasTier := agent.Tier != ""
if hasPin && hasTier {
    return fmt.Errorf("agent %q: cannot set both explicit provider/model pin and tier (pick one)", name)
}
```

If you set both `tier: middle` AND `model: claude-sonnet-4-6` in an agent's frontmatter, loomcycle refuses to start. Pick one path.

---

## 3. Pattern 1 — Single provider, model tiers + model aliases

**Setup:** You have one provider (Anthropic). You want agents to declare `tier: low/middle/high` and pick the matching Anthropic model automatically. You also want short aliases (`sonnet`) so agents can pin a specific model without typing the full ID.

```yaml
# loomcycle.yaml — single-provider, tier-driven

provider_priority:
  - anthropic

tiers:
  low:
    - { provider: anthropic, model: claude-haiku-4-5 }
  middle:
    - { provider: anthropic, model: claude-sonnet-4-6 }
  high:
    - { provider: anthropic, model: claude-opus-4-7 }

# Alias map — left-hand-side is what agents write; resolver expands.
models:
  haiku:  { provider: anthropic, model: claude-haiku-4-5 }
  sonnet: { provider: anthropic, model: claude-sonnet-4-6 }
  opus:   { provider: anthropic, model: claude-opus-4-7 }

# Default for agents without tier or explicit pin (rare; back-compat path).
defaults:
  provider: anthropic
  model:    claude-sonnet-4-6

concurrency:
  max_concurrent_runs: 8
  max_queue_depth: 16
  queue_timeout_ms: 30000
```

### Agent .md examples in this pattern

**Tier-driven** — let the resolver pick the model:

```markdown
---
name: ats-filter
description: Score CV bullets against a job posting; return JSON.
tier: low
allowed_tools: [Read]
---
You are an ATS filter...
```

→ resolves to `(anthropic, claude-haiku-4-5)` via `tiers.low`.

**Alias pin** — agent specifically wants sonnet:

```markdown
---
name: cv-rewriter
description: Rewrites CV text in the user's voice.
model: sonnet
allowed_tools: [Read]
---
You rewrite text while preserving voice...
```

→ resolves to `(anthropic, claude-sonnet-4-6)` via the `models:` alias.

**Full pin** — bypass aliases entirely:

```markdown
---
name: cv-rewriter
provider: anthropic
model: claude-sonnet-4-6
allowed_tools: [Read]
---
```

Same effective result. Use whichever reads more naturally to your team.

### Why this pattern

Operators who only use Anthropic still benefit from the tier abstraction: when Anthropic ships a new model family (haiku-5, sonnet-5), you change ONE place (the `tiers:` block) and every tier-driven agent picks up the new model. Aliases give you the same benefit for explicitly-pinned agents.

---

## 4. Pattern 2 — Multiple providers, model tiers + model names

**Setup:** Anthropic + DeepSeek + Gemini. You want a cost-floor cascade: try DeepSeek first (cheapest), fall through to Gemini, fall through to Anthropic. Some agents (privacy-sensitive ones) override that order.

```yaml
# loomcycle.yaml — multi-provider, cost-floor-first

provider_priority:
  - deepseek          # cost floor
  - gemini            # cheap quality alternative
  - anthropic         # last-resort fallback

tiers:
  low:
    - { provider: deepseek,  model: deepseek-v4-flash }   # 16/16 CAPABLE at $0.0010/pass
    - { provider: gemini,    model: gemini-2.5-flash-lite }
    - { provider: anthropic, model: claude-haiku-4-5 }    # baseline
  middle:
    - { provider: deepseek,  model: deepseek-v4-pro }
    - { provider: gemini,    model: gemini-2.5-pro }
    - { provider: anthropic, model: claude-sonnet-4-6 }
  high:
    # Premium-privacy: anthropic only at high tier.
    - { provider: anthropic, model: claude-sonnet-4-6 }
    - { provider: anthropic, model: claude-opus-4-7 }

models:
  haiku:  { provider: anthropic, model: claude-haiku-4-5 }
  sonnet: { provider: anthropic, model: claude-sonnet-4-6 }
  opus:   { provider: anthropic, model: claude-opus-4-7 }

defaults:
  provider: anthropic
  model:    claude-sonnet-4-6
```

### Cascade behaviour

A `tier: low` agent runs against `deepseek-v4-flash` first. If DeepSeek returns 429 or 5xx — and `fallback_on_error` is true (default) — the resolver re-picks from the same candidate list, this time selecting the next provider (`gemini`). This continues until a candidate succeeds or the cascade is exhausted (`ErrTierUnavailable`).

### Agent .md: per-agent `providers:` override

For one agent that must skip DeepSeek (e.g., a CV-handling agent where you don't want CV text leaving the Anthropic boundary):

```markdown
---
name: cv-adapter
description: Adapts a CV to a target job posting.
tier: middle
providers: [anthropic]         # ← full replacement of provider_priority
allowed_tools: [Read]
---
```

The resolver now walks `[anthropic]` only, so this agent always resolves to `claude-sonnet-4-6` (the only middle-tier anthropic candidate). It never touches DeepSeek or Gemini.

### Agent .md: per-agent `models[tier]:` override

If you want full control over the candidate list (e.g., an analytical agent that benefits from opus, not sonnet):

```markdown
---
name: research-analyst
description: Deep analytical reasoning for the high-tier slot.
tier: high
models:
  high:
    - { provider: anthropic, model: claude-opus-4-7 }
    - { provider: anthropic, model: claude-sonnet-4-6 }   # fallback
allowed_tools: [Read, WebFetch]
---
```

The library `tiers.high` is ignored for this agent; the resolver walks the agent-declared list instead.

### Why this pattern

This is the most common production setup for operators with multiple provider keys. The library `provider_priority` does the cost-cascade work; per-agent overrides handle the exceptions (privacy, capability, cost-cap-on-sensitive-paths).

---

## 5. Pattern 3 — Single provider, multiple user tiers

**Setup:** Only Anthropic, but per-plan model gating: free users get haiku, medium gets sonnet, high gets opus. The agent's `tier:` field stays the same; what changes is **which user's run is asking**, conveyed via the `user_tier` field on the run request.

```yaml
# loomcycle.yaml — single-provider, multi-user-tier

provider_priority:
  - anthropic

tiers:
  low:
    - { provider: anthropic, model: claude-haiku-4-5 }
  middle:
    - { provider: anthropic, model: claude-sonnet-4-6 }
  high:
    - { provider: anthropic, model: claude-opus-4-7 }

models:
  haiku:  { provider: anthropic, model: claude-haiku-4-5 }
  sonnet: { provider: anthropic, model: claude-sonnet-4-6 }
  opus:   { provider: anthropic, model: claude-opus-4-7 }

# v0.8.2+: per-user-class policy overlays.
user_tiers:
  default:
    # Inherits library priority + tiers. Used when a caller omits
    # user_tier or passes an unrecognized name.
    provider_priority: [anthropic]
    fallback_on_error: true

  free:
    # Free users get haiku only — regardless of agent's tier:.
    provider_priority: [anthropic]
    fallback_on_error: true
    tiers:
      low:    [{ provider: anthropic, model: claude-haiku-4-5 }]
      middle: [{ provider: anthropic, model: claude-haiku-4-5 }]   # locked down
      high:   [{ provider: anthropic, model: claude-haiku-4-5 }]   # locked down

  medium:
    # Medium users get haiku + sonnet (no opus).
    provider_priority: [anthropic]
    fallback_on_error: true
    tiers:
      low:    [{ provider: anthropic, model: claude-haiku-4-5 }]
      middle: [{ provider: anthropic, model: claude-sonnet-4-6 }]
      high:   [{ provider: anthropic, model: claude-sonnet-4-6 }]   # cap at sonnet

  high:
    # High users get the full menu.
    provider_priority: [anthropic]
    fallback_on_error: true
    tiers:
      low:    [{ provider: anthropic, model: claude-haiku-4-5 }]
      middle: [{ provider: anthropic, model: claude-sonnet-4-6 }]
      high:   [{ provider: anthropic, model: claude-opus-4-7 }]

defaults:
  provider: anthropic
  model:    claude-sonnet-4-6
```

### How the caller picks a user_tier

The app server includes a `user_tier` field on the run request:

```http
POST /v1/runs
{
  "agent": "my-tier-middle-agent",
  "user_id": "u_42",
  "user_tier": "free",          ← caller-supplied; loomcycle reads it
  "segments": [...]
}
```

Loomcycle looks up `user_tiers.free` in the operator yaml, applies the overlay, and resolves. With the yaml above, a `tier: middle` agent run on `user_tier: free` resolves to `claude-haiku-4-5`. The same agent on `user_tier: high` resolves to `claude-opus-4-7`. The agent .md file doesn't change; the user's plan controls the model.

### Required: the `default` user_tier

If you set `user_tiers:` at all, you must include a `default:` entry. It's the fallback when:
- The caller omits `user_tier` from the run request (e.g., legacy callers)
- The caller passes an unrecognized name (`"premium"` when you only defined `"high"`)

Without it, the resolver has nothing to walk for unknown user_tiers, and runs error out.

### `fallback_on_error`

Per overlay. When `true` (default), a retryable provider error triggers fallback to the next candidate. When `false`, the error surfaces directly to the caller.

Set to `false` on the free tier if you want a strict no-cascade behaviour (the operator doesn't want a free-tier user accidentally falling into a more expensive provider during an outage). Set to `true` everywhere else for resilience.

### Why this pattern

The simplest "SaaS with plan tiers" shape: one provider, but the cost/capability boundary lives in the user_tier overlay rather than in agent .md frontmatter. Adding a new agent: just give it a `tier:` and you don't have to touch each plan's gating — the overlay already handles it.

---

## 6. Pattern 4 — Multiple providers + multiple user tiers (production-grade)

**Setup:** All the providers, all the plan tiers, with a privacy boundary on the top tier. This is what real production loomcycle deployments look like.

```yaml
# loomcycle.yaml — multi-provider, multi-user-tier

provider_priority:
  - ollama-local    # local first when available (denn-desktop.local)
  - ollama          # Ollama Cloud (subscription billing — counts as cost floor)
  - gemini
  - deepseek
  - anthropic
  - openai

# Library tiers — the "default user_tier" backstop.
tiers:
  low:
    - { provider: ollama-local, model: glm-4.7-flash:q4_K_M }
    - { provider: ollama,       model: glm-4.7 }
    - { provider: deepseek,     model: deepseek-v4-flash }
    - { provider: gemini,       model: gemini-2.5-flash-lite }
    - { provider: anthropic,    model: claude-haiku-4-5 }
  middle:
    - { provider: ollama,       model: deepseek-v4-pro }   # cloud-ollama subscription path
    - { provider: deepseek,     model: deepseek-v4-pro }   # per-token fallback
    - { provider: gemini,       model: gemini-2.5-pro }
    - { provider: anthropic,    model: claude-sonnet-4-6 }
  high:
    # Premium-privacy: anthropic only at library high.
    - { provider: anthropic,    model: claude-sonnet-4-6 }

models:
  haiku:  { provider: anthropic, model: claude-haiku-4-5 }
  sonnet: { provider: anthropic, model: claude-sonnet-4-6 }
  opus:   { provider: anthropic, model: claude-opus-4-7 }

user_tiers:
  default:
    # Inherits library priority + tiers. Local-first cascade.
    provider_priority: [ollama-local, ollama, gemini, deepseek, anthropic, openai]
    fallback_on_error: true

  free:
    # Cost cap: subscription-billing only. NO per-token providers.
    # Ollama-local + Ollama Cloud (subscription) + Gemini cloud.
    provider_priority: [ollama-local, ollama, gemini]
    fallback_on_error: true
    tiers:
      low:
        - { provider: ollama-local, model: glm-4.7-flash:q4_K_M }
        - { provider: ollama,       model: glm-4.7 }
        - { provider: gemini,       model: gemini-2.5-flash-lite }
      middle:
        - { provider: ollama,       model: deepseek-v4-pro }
        - { provider: gemini,       model: gemini-2.5-pro }
      high:
        # Defined for safety; free-tier high-tier agents should be
        # blocked at the app's route layer, not just here.
        - { provider: ollama,       model: deepseek-v4-pro }
        - { provider: gemini,       model: gemini-2.5-pro }

  low:
    # Cheapest paid tier. Local floor → cloud subscription → per-token → anthropic.
    provider_priority: [ollama-local, ollama, deepseek, gemini, anthropic]
    fallback_on_error: true
    tiers:
      low:
        - { provider: ollama-local, model: glm-4.7-flash:q4_K_M }
        - { provider: ollama,       model: glm-4.7 }
        - { provider: deepseek,     model: deepseek-v4-flash }
        - { provider: gemini,       model: gemini-2.5-flash-lite }
        - { provider: anthropic,    model: claude-haiku-4-5 }
      middle:
        - { provider: ollama,       model: deepseek-v4-pro }
        - { provider: deepseek,     model: deepseek-v4-pro }
        - { provider: gemini,       model: gemini-2.5-pro }
        - { provider: anthropic,    model: claude-sonnet-4-6 }
      high:
        - { provider: ollama,       model: deepseek-v4-pro }
        - { provider: deepseek,     model: deepseek-v4-pro }
        - { provider: anthropic,    model: claude-sonnet-4-6 }

  medium:
    # Same cost-floor pattern as low, plus anthropic-premium for high-tier agents.
    provider_priority: [ollama-local, ollama, deepseek, gemini, anthropic, openai]
    fallback_on_error: true
    tiers:
      low:
        - { provider: ollama-local, model: glm-4.7-flash:q4_K_M }
        - { provider: ollama,       model: glm-4.7 }
        - { provider: deepseek,     model: deepseek-v4-flash }
        - { provider: gemini,       model: gemini-2.5-flash-lite }
        - { provider: anthropic,    model: claude-haiku-4-5 }
      middle:
        - { provider: ollama,       model: deepseek-v4-pro }
        - { provider: deepseek,     model: deepseek-v4-pro }
        - { provider: gemini,       model: gemini-2.5-pro }
        - { provider: anthropic,    model: claude-sonnet-4-6 }
      high:
        # Medium tier IS allowed anthropic-premium for the high slot.
        - { provider: anthropic,    model: claude-sonnet-4-6 }
        - { provider: anthropic,    model: claude-opus-4-7 }

  high:
    # PREMIUM-PRIVACY. NO third-party LLMs touch user data, EVER.
    # provider_priority alone enforces this — no ollama / gemini /
    # deepseek anywhere. Falls back from anthropic → openai (if key)
    # → hard fail. Never escapes the anthropic/openai boundary.
    provider_priority: [anthropic, openai]
    fallback_on_error: true
    # No tiers: override — library tiers are filtered to anthropic+openai
    # by the provider_priority intersection. With library high tier =
    # [sonnet], high user_tier traffic gets sonnet.

defaults:
  provider: gemini
  model:    gemini-2.5-flash
```

### Routing table for this yaml

| `agent.tier` | `user_tier=free` | `user_tier=low` | `user_tier=medium` | `user_tier=high` |
|---|---|---|---|---|
| `low` | `ollama-local/glm-4.7-flash` | same | same | `anthropic/sonnet` (library high) |
| `middle` | `ollama/deepseek-v4-pro` | `ollama/deepseek-v4-pro` | `ollama/deepseek-v4-pro` | `anthropic/sonnet` |
| `high` | `ollama/deepseek-v4-pro` | `ollama/deepseek-v4-pro` | `anthropic/sonnet` | `anthropic/sonnet` |

(First-pick on a healthy resolver matrix. Stalled candidates fall through to the next entry in the list.)

### Privacy boundary on user_tier=high

The `high` user_tier deliberately omits `tiers:` so the library tiers are inherited — BUT `provider_priority: [anthropic, openai]` filters them. Any candidate whose provider isn't in `[anthropic, openai]` is skipped at resolve time. So a library `tiers.low` that lists `ollama-local/glm` first will, for `user_tier=high`, walk past it and land on `anthropic/haiku`.

This is the **strict privacy boundary**: no matter what library tiers say, a high-tier user's run never touches a third-party cloud. If both anthropic and openai are down, the user gets a hard 503 — never a silent fallback to deepseek.

### Per-agent overrides intersect with user_tier

An agent with `providers: [anthropic]` on a request with `user_tier=free` (whose `provider_priority` is `[ollama-local, ollama, gemini]`) produces an **empty intersection** → `ErrTierAgentNotAvailable`. The app server sees this as "this user's plan doesn't cover this agent — upgrade required." The same agent on `user_tier=high` resolves cleanly.

This is the load-bearing security property: the resolver enforces "the agent can use anthropic" AND "the user is allowed to use anthropic" at the same gate.

### Why this pattern

It's what real production deployments look like. The library is the backstop; user_tiers carve out specific cost/privacy/capability boundaries; per-agent overrides handle the exceptions. Most agents stay tier-driven and inherit from this matrix.

---

## 7. Agent `.md` frontmatter reference

Agent files live under `LOOMCYCLE_AGENTS_ROOT` (set in the env file). Each `<name>.md` has YAML frontmatter between `---` delimiters; the body is the system prompt.

### Frontmatter fields

Parsed at `internal/agents/loader.go:199` (the `frontmatter` struct):

| Field | Type | Purpose | Notes |
|---|---|---|---|
| `name` | string | Agent identifier | Defaults to filename minus `.md`. If set, must match the filename. |
| `description` | string | Human summary | Surfaces in operator tooling; not sent to the LLM as part of the prompt. |
| **Model resolution** | | | |
| `provider` | string | Explicit provider pin | XOR with `tier:`. With `model:` forms the pin path. |
| `model` | string | Model alias OR full model ID | Aliases expand via `models:` map at `config.go:1370`. |
| `tier` | string | `low` / `middle` / `high` | XOR with `provider`/`model`. Triggers tier-driven resolution. |
| `providers` | `[]string` | Per-agent provider priority | Full replacement of library `provider_priority` for this agent. |
| `models` | `map[tier][]TierCandidate` | Per-agent tier candidate lists | Full replacement of library `tiers[]` for this agent. |
| `effort` | string | `low` / `medium` / `high` | Reasoning-effort hint. Anthropic + OpenAI honour it; Ollama ignores. |
| `max_tokens` | int | Per-iteration assistant output cap | 0 = provider default. |
| **Tool fields** | | | |
| `allowed_tools` | `[]string` | Tool allowlist (loomcycle form) | Empty list = zero tools. Always wins over `tools:`. |
| `tools` | string OR `[]string` | Claude-Code-compatible form | Comma-string or list. Tolerated for Claude-Code compatibility; `allowed_tools` takes precedence when both are set. |
| `skills` | `[]string` | Skill bundle | Skills bundle a system-prompt fragment + tool allowlist contribution; skill's tools must be a subset of the agent's `allowed_tools`. |
| **System prompt** | | | |
| (body) | string | Inline system prompt | Everything after the closing `---` line. |
| `system_prompt_file` | string | External prompt path | Mutually exclusive with body. Useful for sharing prompts across agents. |
| **Capability fields** | | | |
| `memory_scopes` | `[]string` | Memory tool scope gate | `agent` / `user`. Empty = default-deny (no Memory tool access). |
| `memory_quota_bytes` | int | Per-agent memory byte cap | 0 = global default. |
| `channels` | object | Channel tool ACL | `{publish: [...], subscribe: [...]}`. |
| `agent_def_scopes` | `[]string` | AgentDef tool scope | `self` / `descendants` / `named:<name>` / `any`. Empty = default-deny. |
| `evaluation_scopes` | `[]string` | Evaluation tool scope | `submit_self` / `submit_siblings` / etc. Empty = default-deny. |

### Worked examples (real agents from jobs-search-agent)

**Bare tier-driven** — resolver picks everything:

```yaml
---
name: feedback-triage
description: Triage free-form feedback...
tier: low
allowed_tools: []
---
```

**MCP tools + tier**:

```yaml
---
name: qa-agent
description: Q&A answer generator for job applications.
tools: mcp__jobs__getAgentContext
tier: middle
allowed_tools:
  - mcp__jobs__getAgentContext
  - mcp__jobs__getApplication
  - mcp__jobs__postApplicationQaAnswers
---
```

Note: `tools:` (the Claude-Code form) is present so the same file works in Claude Code, but `allowed_tools:` (the loomcycle form) takes precedence and is the authoritative list at runtime.

**Alias pin with skills** — privacy-sensitive agent locked to sonnet:

```yaml
---
name: cv-rewriter
description: Rewrites CV or Cover Letter text...
tools: mcp__jobs__getAgentContext
allowed_tools:
  - mcp__jobs__getAgentContext
  - Read
  - Skill
skills:
  - voice-applier
  - cv-voice-applier
model: sonnet
---
```

No `tier:` — uses pin path. `model: sonnet` expands via `models:` alias to `(anthropic, claude-sonnet-4-6)`. This agent never falls through to a non-Anthropic provider.

**Multi-tool research agent**:

```yaml
---
name: company-researcher
description: Researches ONE company for a job application...
tools: WebSearch, WebFetch, mcp__brave-search__brave_web_search
tier: middle
allowed_tools:
  - WebSearch
  - WebFetch
  - mcp__brave-search__brave_web_search
---
```

Mix of built-in tools (WebSearch, WebFetch) and an MCP tool. Tier-driven resolution applies.

### Claude-Code compatibility

The same `.md` file works in both Claude Code and loomcycle. **Claude-Code-honoured fields**: `name`, `description`, `tools` (comma-string), `model`. **Loomcycle extensions**: `tier`, `models`, `providers`, `effort`, `max_tokens`, `skills`, `allowed_tools` (list form), `system_prompt_file`, `memory_scopes`, `memory_quota_bytes`, `channels`, `agent_def_scopes`, `evaluation_scopes`. Claude Code ignores unknown keys; loomcycle treats the format as a superset. Keep your agents portable by including both `tools:` (Claude Code shape) and `allowed_tools:` (loomcycle shape) when you want the same file used in both.

### Operator-yaml `agents:` overlay

The operator yaml's `agents:` map can override any frontmatter field at the deployment level. Useful when you want different model resolution per deployment without forking the .md files. Merge logic at `internal/config/config.go:1531`:

- Scalar fields (string, int): YAML non-zero value wins
- Slice/map fields: YAML `nil` keeps the discovered value; YAML non-nil (even `[]`) is an explicit override
- `system_prompt` and `system_prompt_file` are mutually exclusive — setting one in YAML clears the other from the merged struct

Example overlay:

```yaml
# loomcycle.yaml
agents:
  cv-rewriter:
    # Override the agent's pin for THIS deployment only.
    # Removes the model: sonnet from the merged config and uses tier instead.
    tier: high
    model: ""
    provider: ""
```

(Setting `model: ""` is the explicit "clear" — without it, the discovered `sonnet` would stay.)

---

## 8. Conflict resolution — what wins when

Single reference table:

| Conflict | Winner | Where enforced |
|---|---|---|
| `tier:` AND (`provider:` / `model:`) both set | **Config-load fails** | `config.go:1985` |
| `allowed_tools:` AND `tools:` both set | `allowed_tools:` wins | `loader.go:295` |
| Body AND `system_prompt_file:` both set | Setting either via YAML overlay clears the other | `config.go:1564` |
| Agent `providers:` AND user_tier `provider_priority` both set | **Intersection** (agent-order); empty → `ErrTierAgentNotAvailable` | `matrix.go:440` |
| Agent `models[tier]:` set | Replaces library `tiers[tier]` AND user_tier `tiers[tier]` for this agent | `matrix.go` candidate-list build |
| user_tier `tiers[tier]:` set (no agent override) | Replaces library `tiers[tier]` | resolver candidate-list build |
| Discovered .md field AND operator-yaml `agents:` overlay both set | YAML non-zero wins; nil slice keeps .md value | `config.go:1531` |
| `model: sonnet` (alias) AND `models:` map has `sonnet` | Alias expands to `{provider, model}` from the map | `config.go:1370` |
| `model: claude-sonnet-4-6` (literal, no alias) | Used as-is as the model ID | same path |
| Probe says provider unreachable | Resolver skips all that provider's candidates | `matrix.go` per-candidate check |
| Provider has no API key set | Marked excluded, treated like unreachable | startup probe |

---

## 9. Validation + verification

### At config-load

Loomcycle validates the yaml at startup. Common errors:

| Error message | What's wrong |
|---|---|
| `agent X: cannot set both explicit provider/model pin and tier (pick one)` | `tier:` AND `provider:`/`model:` both present on the same agent |
| `agent X: no model, no tier, and no defaults.model` | Agent has neither and operator has no `defaults.model` fallback |
| `user_tiers: missing "default" entry` | You set `user_tiers:` but didn't include `default:` |
| `unknown provider: X` | Provider in `provider_priority` or a candidate doesn't match a registered driver |
| `model alias cycle: X → Y → X` | `models:` map has a cycle |

### At runtime — inspect the resolver matrix

`/v1/_resolver` returns the live availability matrix (auth-gated by `LOOMCYCLE_AUTH_TOKEN`):

```sh
curl -s -H "Authorization: Bearer $LOOMCYCLE_AUTH_TOKEN" \
  http://localhost:8787/v1/_resolver | jq .
```

Shows: which providers are reachable, which models each lists, which models are stalled, the last probe time. Useful for confirming "is ollama-local actually probing successfully?"

### At runtime — force an immediate re-probe

The resolver re-probes every provider on a fixed interval (`LOOMCYCLE_RESOLVE_PROBE_INTERVAL_MS`, default 15 min). If a transient outage (DNS hiccup, brief upstream blip, the VM losing egress for a few seconds) stalls every provider mid-probe, runs can 503 for up to a full interval until the next tick. `POST /v1/_resolve/probe` triggers an immediate, synchronous re-probe so an operator can unstick the matrix without a restart (a restart drops in-flight runs):

```sh
curl -s -X POST -H "Authorization: Bearer $LOOMCYCLE_AUTH_TOKEN" \
  http://localhost:8787/v1/_resolve/probe | jq .
```

Returns the **post-probe** matrix in the same shape as `GET /v1/_resolver`. A provider still unreachable after the probe comes back as `reachable: false` with its `last_error` set — that's data, not an error (200). The endpoint only 503s when it can't probe at all: `resolver_unavailable` (degraded startup, no resolver) or `probe_unavailable` (no probe loop wired, e.g. `--no-http`). Also handy for post-deploy / post-config validation before serving traffic.

The same operation is available through every transport that consumes the Connector: gRPC `ResolveProbe`, the MCP meta-tool `resolve_probe`, and the TypeScript client's `client.resolveProbe()`.

### At runtime — confirm the resolved (provider, model)

Every run's SSE stream includes an early event carrying the resolved pair. Smoke run:

```sh
curl -N -H "Authorization: Bearer $LOOMCYCLE_AUTH_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"agent":"<your-agent>","user_tier":"free","segments":[{"role":"user","content":[{"type":"trusted-text","text":"hello"}]}]}' \
  http://localhost:8787/v1/runs
```

The first `event: started` (or `event: resolved`) frame carries `provider=...` and `model=...`. Confirms your config picked what you expected.

After v0.8.16 (PR #116), the model is also persisted at run start, so `GET /v1/users/{id}/agents` shows it during the run, not just at completion.

---

## 10. Cross-references

- [`loomcycle.example.yaml`](../loomcycle.example.yaml) — the repo-root reference yaml. All six user_tiers wired, inline comments on every section. Copy-paste and edit.
- [`docs/MCP_INTEGRATION.md`](MCP_INTEGRATION.md) — MCP server configuration (deliberately out of scope for this doc).
- [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) — broader runtime context, provider driver table, probe semantics.
- [`docs/TOOLS.md`](TOOLS.md) — tool policy and built-in tool reference (the `allowed_tools` / `tools` axis).
- [`docs/POSTGRES.md`](POSTGRES.md) — storage backend configuration.
- [`docs/PLAN.md`](PLAN.md) — historical design rationale, including the v0.8.2 `user_tiers` RFC and the precedence design decisions.
- [`examples/observability/`](../examples/observability/) — three drop-in observability profiles (Grafana+Tempo self-hosted / Honeycomb / Datadog) for sending loomcycle's OTEL traces + Prometheus metrics to your existing stack. Five-minute quickstart per profile.
- MCP spec: https://modelcontextprotocol.io (only relevant if you're also wiring MCP servers — see `MCP_INTEGRATION.md` first).

## 11. Code path index

Single jump-list of every file:line cited above. As of v0.8.16:

| What | Where |
|---|---|
| Operator yaml `Config` struct + top-level keys | `internal/config/config.go:22` |
| `ModelRef` (alias map value type) | `internal/config/config.go:154` |
| `UserTier` overlay struct | `internal/config/config.go:177` |
| Alias expansion (`ResolveAgentDefModel`) | `internal/config/config.go:1366–1390` |
| Agent .md / yaml merge logic | `internal/config/config.go:1531–1612` |
| `system_prompt` / `system_prompt_file` mutual-exclusion clear | `internal/config/config.go:1564` |
| Pin XOR Tier validation | `internal/config/config.go:1985` |
| Frontmatter struct (every accepted field) | `internal/agents/loader.go:199` |
| `tools` vs `allowed_tools` precedence | `internal/agents/loader.go:295` |
| Resolver entry — `Resolve(req)` | `internal/resolve/matrix.go:281` |
| `priorityFor` intersection logic | `internal/resolve/matrix.go:440` |
| `resolvePin` (pin path) | `internal/resolve/matrix.go:293` |
