---
contract_version: "2.1"
target_module: "ratatoskr.tier3"
scope: "New module `ratatoskr.tier3` exposing Worldtree's Tier 3 (consumer-defined) agent lifecycle: `define_agent` (POST /agents/define), `patch_agent` (PATCH /agents/<id>), `delete_agent` (DELETE /agents/<id>), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 <define|patch|delete>`) that mirrors `ratatoskr.cli`'s env-var posture (`WORLDTREE_API_URL`, `WORLDTREE_API_KEY`). Convention-aligned with `ratatoskr.sessions` (issue #2): caller-owned httpx.AsyncClient, no Worldtree imports, response parsing into frozen dataclass, exception `.body` truncated to `[:1024]`. Picker stays generic — agents with `:` in agent_id show in the list like any other per issue #8's out-of-scope clause. Goal: ratatoskr operators can define, mutate, and delete Tier 3 agents from the command line, then exercise the full session flow against them to observe how Tier 3 agent_ids (colon-containing) flow through the picker / session-create / SSE stream."
depends_on:
  - "httpx"
used_by: []
language: "python"
complexity: "low"
estimated_loc: 250
confidence: 0.9
assumptions:
  - "Tier 3 endpoints land at the same `WORLDTREE_API_URL` as the rest of the Conversation API — no separate hostname / port. Auth via the same bearer key. The caller's user_id is derived server-side from the API key's owner; the agent's `agent_id` is constructed as `<auth_user_id>:<agent_name>`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer <key>` returned `agent_id=ratatoskr:smoke-test`, `user_id=ratatoskr`."
  - "Per Worldtree spec §2576-2750: `agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable after definition. `user_id` is derived from the auth, must be slug-safe (`[a-z][a-z0-9-]{2,63}` per Phase 2.0 gate). PATCH accepts ONLY `system_prompt` and/or `model`; any other key (including the immutable `agent_name`, `user_id`, or layer fields `persona`/`motivational`/`valence`/`memory` — even with `null` value) returns 422 `field_not_mutable` BEFORE the DB lookup."
  - "**Layer fields are explicitly null** on define. Phase 2.0 ships baseline addressing + ownership + lifecycle only; `persona` / `motivational` / `valence` / `memory` are schema-reserved. Non-null on these → 422 `layer_deferred`. The module's `define_agent` does NOT expose these as parameters at all — sending them would require an amendment when a future Phase enables them."
  - "**`model` field is a provider model ID, not a profile alias.** Live probe found: `model='default'` (an llm_profiles profile name) returns 422 `model_not_available`; `model='qwen3.6-35-a3b'` (an actual provider model ID) returns 201. The CLI / module take the string verbatim and pass through — validation is server-side. Operators discover valid IDs via the model `metadata` on existing sessions or out-of-band."
  - "**Quota: 50 Tier 3 agents per Heimdall key.** 51st define → 429 `agent_quota_exceeded` with `Retry-After: 0`. The module raises `Tier3QuotaExceeded(retry_after=0)` — the retry_after field captures the header value verbatim for forward-compat if Worldtree later returns a non-zero throttle."
  - "**Key-revocation cascade is server-side.** When an API key is revoked (`DELETE /admin/keys/{key_id}`), every Tier 3 agent with `owner_key_hash` equal to the revoked key's hash is soft-deleted in the same SQL transaction. Active sessions on those agents return 401 `auth_revoked` on next message. The ratatoskr module doesn't track or simulate this — operators discover it via runtime 401s and the admin-side audit log."
  - "**Picker integration is implicit** — no changes to `ratatoskr.tui.AgentPickerApp` for this issue. Tier 3 agents appear in `GET /agents` if defined and the picker's existing format `{agent_id} · {name} — {description}` renders the colon-containing agent_id without special-casing. Per issue #8 out-of-scope clause, ratatoskr does not visually distinguish Tier 1 vs Tier 3 in the picker — same UX surface."
  - "**Session-create with colon-containing agent_id works unchanged.** Issue #5 already routes `end_user_id` into the POST /sessions body, which Tier 3 session-create requires from Phase 2.0 (per spec §2649-2664). No `ratatoskr.sessions` change needed."
  - "**CLI uses argparse with subparsers** (define / patch / delete). The subparsers entry point lives at `python -m ratatoskr.tier3` via `__main__.py`. Output on success: prints a one-line summary (`defined ratatoskr:wizard (qwen3.6-35-a3b)` / `patched ratatoskr:wizard` / `deleted ratatoskr:wizard`). Output on error: `[<error_code>] <message>` to stderr + non-zero exit. Exit codes mirror `ratatoskr.cli`: 0 happy / 10 usage / 11 auth / 20 api-failure / 21 network."
  - "**No `list` subcommand in v1.** A `tier3 list` operation would have to filter `GET /agents` by prefix-matching the caller's user_id, but that prefix isn't exposed in the response — only the agent_id is, and you'd have to introspect the auth's user_id. Operators discover their own Tier 3 agents by reading the `GET /agents` list (which the picker already surfaces) and looking for `<their-user-id>:*` entries. Add `list` in a follow-up if operators report friction."
  - "**Module is standalone**: does NOT import or interact with `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` / `ratatoskr.cli` beyond reusing the `USER_AGENT` constant from `ratatoskr.cli`. Cross-module use is one-way (cli supplies the user-agent string; tier3 does not import sessions). This keeps the module surface minimal and testable in isolation."
  - "**The CLI's `python -m ratatoskr.tier3` entry point uses sys.argv handling that mirrors `ratatoskr.cli`** — a top-level `main(argv: list[str] | None = None) -> int` function that argparse-dispatches to subcommand handlers. Each subcommand handler is an async coroutine wrapped by `asyncio.run(...)`. Auth resolution: `--api-key` flag > `$WORLDTREE_API_KEY` env > `_AuthError` (exit 11). Server URL: `--server` > `$WORLDTREE_API_URL` > default `http://localhost:8000` (same default as `ratatoskr.cli`)."
  - "**Tests use `respx` for HTTP mocking** (same pattern as `tests/test_sessions.py`). New test file: `tests/test_tier3.py`. Cover all success + error response codes per the ERROR_ROUTING matrix below. No live network in unit tests — the live smoke is in the acceptance criteria, not the unit tests."
open_questions:
  - "Should `define_agent` accept an optional `bifrost` parameter for Bifrost-bound Tier 3 sessions? The spec §2658 shows `bifrost` as a session-create field (not define-time). Draft: no — Bifrost binding is per-session; if a Tier 3 agent needs Bifrost on every session, that's an orthogonal feature on POST /sessions, not POST /agents/define. Issue #5's `--end-user-id` already covers the session-create-side parameters."
  - "Should the CLI also offer `--end-user-id` for sessions created via tier3 + ratatoskr-cli composition? Draft: no — once an agent is defined, operators use the main `ratatoskr --new --agent <id> --end-user-id <eid>` flow; tier3 CLI is define/patch/delete only."
  - "Should `delete_agent` support a `--force` flag for 'really delete even if active sessions exist'? Per spec §2634-2639, `DELETE` already cancels active sessions and revokes the per-resource scope grant on the owner — there's no soft fail. Draft: no — the spec's hard-delete-with-cascade behavior is the right shape; ratatoskr doesn't need to wrap it."
prd:
  issue: 15
  issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/15"
  body_sha256_16: "03367d7b451ab17f"
  lock_in_comment_id: null
  lock_in_sha256_16: null
  lock_in_at: null
  pinned_at: "2026-05-25T03:21:38+00:00"
dependencies:
  - issue: 2
    path: "src/ratatoskr/sessions.py"
    reason: "Convention dependency, not a code dependency. Issue #2 (`ratatoskr.sessions`) is the posture template: caller-owned httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]. `ratatoskr.tier3` follows the same shape verbatim."
  - issue: 3
    path: "src/ratatoskr/cli.py"
    reason: "Convention dependency only. `ratatoskr.tier3.__main__` mirrors `ratatoskr.cli`'s argparse + env-fallback + exit-code shape. Imports `USER_AGENT` from `ratatoskr.cli` so outbound HTTP carries the same identity string."
---

# Tier 3 — Consumer-defined agent lifecycle module

## Context

Worldtree's Tier 3 (Phase 2.0, spec §2576-2750) lets the consumer define their own agents at `<user_id>:<agent_name>`. The agent's `user_id` is the auth's user identity (derived from the API key's owner); the `agent_name` is supplied at define-time. The lifecycle is owner-only — only the key that defined an agent can patch / delete it (modulo the key-revocation cascade).

`ratatoskr.tier3` exposes this lifecycle as a Python module + small CLI tool. Picker integration is implicit (Tier 3 agents already appear in `GET /agents` per issue #8). Session-create works unchanged through `ratatoskr.sessions.create_session` since the colon-containing agent_id is opaque to that layer.

## Public surface

```python
@dataclass(frozen=True)
class Tier3AgentInfo:
    """Worldtree Tier 3 agent envelope returned by define / patch."""

    agent_id: str       # f"{user_id}:{agent_name}"
    user_id: str
    agent_name: str
    system_prompt: str
    model: str
    created_at: str     # ISO 8601 with offset
    updated_at: str     # ISO 8601 with offset


async def define_agent(
    client: httpx.AsyncClient,
    *,
    agent_name: str,
    system_prompt: str,
    model: str,
) -> Tier3AgentInfo:
    """POST /agents/define → 201 with Tier3AgentInfo. See FN define_agent."""


async def patch_agent(
    client: httpx.AsyncClient,
    agent_id: str,
    *,
    system_prompt: str | None = None,
    model: str | None = None,
) -> Tier3AgentInfo:
    """PATCH /agents/<id> → 200 with updated Tier3AgentInfo. See FN patch_agent."""


async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
    """DELETE /agents/<id> → 204. See FN delete_agent."""
```

## Exception classes

```python
class Tier3QuotaExceeded(Exception):
    """429 agent_quota_exceeded — 50-agent cap reached on the Heimdall key."""
    def __init__(self, *, retry_after: int) -> None: ...
    retry_after: int

class Tier3UserIdUnsupported(Exception):
    """403 tier3_user_id_unsupported — auth's user_id not slug-safe."""

class Tier3FieldNotMutable(Exception):
    """422 field_not_mutable — PATCH carrying an immutable key."""
    def __init__(self, *, field: str | None) -> None: ...
    field: str | None

class Tier3LayerDeferred(Exception):
    """422 layer_deferred — define carrying non-null layer field."""
    def __init__(self, *, field: str | None) -> None: ...
    field: str | None

class Tier3AgentNotFound(Exception):
    """404 — patch/delete on non-existent agent."""
    def __init__(self, *, agent_id: str) -> None: ...
    agent_id: str

# Reused from ratatoskr.sessions (one-way import — sessions doesn't depend on tier3):
# SessionApiFailed(status, body) for all other non-2xx responses.
```

## Functions

### FN define_agent

```
FN define_agent(
    client: httpx.AsyncClient,
    *, agent_name: str, system_prompt: str, model: str,
) -> Tier3AgentInfo
BRIEF: POST /agents/define → 201 with Tier3AgentInfo.

PRE-001: agent_name matches `[a-z][a-z0-9-]{2,63}` (slug guard — client-side
         assert; the server enforces too, but this prevents wire round-trip
         for trivially-bad input).
PRE-002: system_prompt is non-empty.
PRE-003: model is non-empty.

STEPS:
  1. assert PRE-001/002/003.
  2. body = {
       "agent_name": agent_name,
       "system_prompt": system_prompt,
       "model": model,
     }
  3. resp = await client.post("/agents/define", json=body)
  4. ROUTE response status:
     201 → parse body into Tier3AgentInfo, return.
     422 → inspect error_code:
       layer_deferred → raise Tier3LayerDeferred(field=err.get("field"))
       (others) → raise SessionApiFailed(status=422, body=resp.content)
     403 + tier3_user_id_unsupported → raise Tier3UserIdUnsupported
     429 → raise Tier3QuotaExceeded(retry_after=int(resp.headers.get("Retry-After", 0)))
     other → raise SessionApiFailed(status, body)

POST-001: returned Tier3AgentInfo has agent_id of shape "<user_id>:<agent_name>".
```

### FN patch_agent

```
FN patch_agent(
    client: httpx.AsyncClient, agent_id: str,
    *, system_prompt: str | None = None, model: str | None = None,
) -> Tier3AgentInfo
BRIEF: PATCH /agents/<id> → 200 with updated Tier3AgentInfo.

PRE-001: agent_id contains `:` (Tier 3 shape).
PRE-002: at least one of system_prompt or model is non-None (no-op patches
         are still server-accepted but client-side assert avoids the round-trip).

STEPS:
  1. assert PRE-001/002.
  2. body = {}; if system_prompt is not None: body["system_prompt"] = system_prompt;
     if model is not None: body["model"] = model.
  3. resp = await client.patch(f"/agents/{agent_id}", json=body)
  4. ROUTE response status:
     200 → parse, return.
     404 → raise Tier3AgentNotFound(agent_id=agent_id)
     422 + field_not_mutable → raise Tier3FieldNotMutable(field=err.get("field"))
     other → raise SessionApiFailed(status, body)
```

### FN delete_agent

```
FN delete_agent(client: httpx.AsyncClient, agent_id: str) -> None
BRIEF: DELETE /agents/<id> → 204.

PRE-001: agent_id contains `:` (Tier 3 shape).

STEPS:
  1. assert PRE-001.
  2. resp = await client.delete(f"/agents/{agent_id}")
  3. ROUTE response status:
     204 → return None.
     404 → raise Tier3AgentNotFound(agent_id=agent_id)
     other → raise SessionApiFailed(status, body)
```

## CLI surface (`python -m ratatoskr.tier3`)

```
$ python -m ratatoskr.tier3 define --name wizard \
    --system-prompt "You are a guided-elicitation wizard..." \
    --model qwen3.6-35-a3b
defined ratatoskr:wizard (qwen3.6-35-a3b)

$ python -m ratatoskr.tier3 patch ratatoskr:wizard --system-prompt "New prompt"
patched ratatoskr:wizard

$ python -m ratatoskr.tier3 delete ratatoskr:wizard
deleted ratatoskr:wizard
```

Auth + server URL: same env-var fallback as `ratatoskr.cli`. Exit codes: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network).

## Invariants

- **INV-001**: `define_agent` request body carries exactly `{agent_name, system_prompt, model}` — no layer fields, no `bifrost`, no `metadata`. Phase 2.0 baseline shape only.
- **INV-002**: `patch_agent` request body carries ONLY `system_prompt` and/or `model` — every other key is omitted. Server-side 422 `field_not_mutable` is the safety net; client-side body-construction is the first line.
- **INV-003**: `delete_agent` is fire-and-confirm — no body, no retry, no soft-delete. Cascade handling is server-side; ratatoskr doesn't track it.
- **INV-004**: All exceptions carry a `[:1024]` body cap (when applicable) per the issue #2 convention.
- **INV-005**: CLI auth resolution mirrors `ratatoskr.cli`: `--api-key` flag > `$WORLDTREE_API_KEY` > exit 11.
- **INV-006**: CLI server URL resolution mirrors `ratatoskr.cli`: `--server` > `$WORLDTREE_API_URL` > `http://localhost:8000`.
- **INV-007**: Module never imports `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` (one-way: only `cli.USER_AGENT` is imported, and only by `__main__.py` for the outbound User-Agent header).
- **INV-008**: All HTTP through caller-owned `httpx.AsyncClient` — module never constructs its own client. (`__main__` constructs one for the CLI entry point per ratatoskr.cli's pattern.)

## TESTS (tests/test_tier3.py — new file)

```
- test_define_happy: 201 + full response shape → Tier3AgentInfo populated.
- test_define_quota_exceeded: 429 + Retry-After header → Tier3QuotaExceeded(retry_after=N).
- test_define_user_id_unsupported: 403 tier3_user_id_unsupported → Tier3UserIdUnsupported.
- test_define_layer_deferred_persona: 422 layer_deferred → Tier3LayerDeferred (would only fire if the body sent a layer field; the module never sends one, so this asserts server-side defense but reflecting a 422 we don't actually generate. Test exercises the response path, not the request).
- test_define_bad_slug: PRE-001 assertion fires before HTTP for agent_name="X" (uppercase) or "ab" (too short).
- test_define_empty_prompt: PRE-002 assertion fires for empty system_prompt.
- test_define_other_5xx: 503 → SessionApiFailed(status=503).
- test_patch_happy_both_fields: 200 + updated body → Tier3AgentInfo.
- test_patch_happy_single_field: 200 with only system_prompt set; body omits model.
- test_patch_field_not_mutable: 422 field_not_mutable → Tier3FieldNotMutable.
- test_patch_404: 404 → Tier3AgentNotFound(agent_id=...).
- test_patch_no_args: PRE-002 assertion fires (both None).
- test_patch_non_tier3_id: PRE-001 assertion fires for agent_id without `:`.
- test_delete_happy: 204 → returns None.
- test_delete_404: 404 → Tier3AgentNotFound.
- test_delete_non_tier3_id: PRE-001 assertion fires.
- test_delete_other_5xx: 500 → SessionApiFailed.
- test_cli_define_happy: argv → 201 mock → stdout="defined ratatoskr:wizard (qwen3.6-35-a3b)" + exit 0.
- test_cli_patch_happy: argv → 200 mock → stdout="patched ratatoskr:wizard" + exit 0.
- test_cli_delete_happy: argv → 204 mock → stdout="deleted ratatoskr:wizard" + exit 0.
- test_cli_missing_auth: no API key → stderr "[auth_error]" + exit 11.
- test_cli_api_failed: 500 mock → stderr "[api_failed]" + exit 20.
```

## ERROR_ROUTING (module + CLI)

| HTTP shape | error_code | Exception (module) | CLI label | Exit |
|---|---|---|---|---|
| 201 / 200 / 204 | — | (none — happy) | one-line confirmation on stdout | 0 |
| 429 | agent_quota_exceeded | `Tier3QuotaExceeded(retry_after=N)` | `[quota_exceeded] retry_after=N` | 20 |
| 403 | tier3_user_id_unsupported | `Tier3UserIdUnsupported` | `[user_id_unsupported]` | 20 |
| 404 | — | `Tier3AgentNotFound(agent_id=...)` | `[agent_not_found] <id>` | 20 |
| 422 | field_not_mutable | `Tier3FieldNotMutable(field=...)` | `[field_not_mutable] field=...` | 20 |
| 422 | layer_deferred | `Tier3LayerDeferred(field=...)` | `[layer_deferred] field=...` | 20 |
| any other non-2xx | — | `SessionApiFailed(status, body)` | `[api_failed] status=N body=...` | 20 |
| httpx.ConnectError / ReadTimeout / TransportError | — | propagates | `[network_error] T: M` | 21 |
| PRE-001/002/003 assertion violation | — | `AssertionError` | `[usage_error] <msg>` | 10 |
| no auth | — | `_AuthError` (reused from cli) | `[auth_error] no API key` | 11 |

## Layout after this module lands

```
src/ratatoskr/
  __init__.py
  cli.py            (existing, unchanged)
  sessions.py       (existing, unchanged)
  sse_client.py     (existing, unchanged)
  tui.py            (existing, unchanged)
  tier3.py          NEW
  __main__/         (no change — main cli still entry-point)

# CLI invocation:
$ python -m ratatoskr.tier3 define --name wizard ...
$ python -m ratatoskr.tier3 patch ratatoskr:wizard ...
$ python -m ratatoskr.tier3 delete ratatoskr:wizard
```
