---
contract_version: "2.1"
target_module: "ratatoskr.sessions"
scope: "Startup agent picker for TUI mode when `--new` is passed without `--agent`. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions` gains `list_agents()` (GET /agents) returning `list[AgentInfo]` (new frozen dataclass with omit-when-null defaults mirroring SessionInfo's INV-001/INV-002 pattern); `ratatoskr.cli` softens `--agent` requirement from absolute to mode-conditional (`--send --new` still requires it; bare `--new` accepts None; `--session` still forbids it); `ratatoskr.tui._resolve_then_run` gains a pre-create branch that, when `args.new and args.agent_id is None`, calls `list_agents(client)` then runs a dedicated tiny `AgentPickerApp` (separate Textual `App` instance, opens before the main `RatatoskrApp`) whose `run_async()` returns the chosen `agent_id` (or `None` on Esc/Ctrl-D for clean exit). No new files; no wire-level surface change beyond the new endpoint hit. Composes naturally with #5 (`--end-user-id`): both thread through `ParsedArgs` before any App opens."
depends_on:
  - "httpx"
  - "textual"
used_by:
  - "ratatoskr.cli"
  - "ratatoskr.tui"
language: "python"
complexity: "low"
estimated_loc: 200
confidence: 0.85
assumptions:
  - "Worldtree spec pin (`docs/conversation-api-spec.md` v0.19.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) declares `GET /agents` at §832: returns 200 with a JSON array of agent objects. Three fields always present (`agent_id`, `name`, `description`); five optional with omit-when-null/omit-when-empty semantics (`version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). No pagination, no filters, no auth-scope requirement beyond bearer-authenticated (worldtree-dev confirmed 2026-05-23: Tier 1 `agent.list` baseline scope covers it; same auth posture as POST /sessions)."
  - "`AgentInfo` is a frozen dataclass on `ratatoskr.sessions` (alongside `SessionInfo` / `SessionPage`) carrying all 8 fields. Origin-conditional defaults mirror INV-001/INV-002 from issue #2: required fields (`agent_id`, `name`, `description`) take the response value verbatim; optional fields default to `None` (scalar `version`) or empty container (`capabilities=[]`, `supported_models=[]`, `persona_traits={}`, `ui_hints={}`) when omitted from the response. Empty containers (NOT `None`) for collection-shaped optionals so caller code can branch on truthiness without `is None` ceremony."
  - "`list_agents` uses the same caller-owned-client posture as `create_session` / `list_sessions`: takes `httpx.AsyncClient`, returns `list[AgentInfo]`, raises `SessionApiFailed(status, body)` on any non-200 response. No new exception type — list_agents' failure modes (auth, transport, server-side 5xx) all map cleanly to the existing `SessionApiFailed` shape. The module's posture against shared types with sse_client (`ratatoskr.sessions` issue #1 convention dependency) is preserved."
  - "**CLI surface change is mode-conditional, not flag-removal**: `--agent` becomes optional ONLY when `--new` is passed AND `--send` is NOT passed (i.e., bare TUI-mode create). `--send --new` still raises `UsageError('--agent is required when --new is passed in --send mode')` because non-interactive --send mode has no way to prompt. `--session <id>` still forbids `--agent` (preserves issue #3 INV-004 mutual-exclusion). The single existing line `if ns.new and not ns.agent: raise UsageError(...)` in `_parse_args` STEP 3 splits into two conditionals that distinguish on `ns.send`."
  - "**Picker is its own App, not a Screen within RatatoskrApp** (deliberate divergence from the issue body's 'pushed onto the App's screen stack' phrasing, which predated issue #6's refactor). Reason: issue #6's load-bearing invariant is that session-resolution + startup errors land on the operator's REAL stderr before any alt-screen opens. `list_agents` failures (network, auth, server error) need that same property. Doing it via a Screen inside RatatoskrApp re-introduces the alt-screen-eats-stderr problem #6 fixed. Doing it via a separate `AgentPickerApp` opened in `_resolve_then_run` (before `RatatoskrApp`) preserves #6's invariant: `list_agents` errors print to stderr and short-circuit BEFORE the picker's alt-screen opens; picker errors (which don't really exist — it's pure UI navigation) are bounded; chosen `agent_id` flows back through `app.run_async()`'s return value."
  - "**Two alt-screen cycles is acceptable** (picker opens + closes; main RatatoskrApp opens). Textual's `App.run_async()` handles alt-screen entry + restoration cleanly per-instance. The visible-flicker cost is one quick alt-screen flash between picker dismissal and main App mount; the architectural cost of avoiding this (Screen-within-App, breaking #6) is higher than the cosmetic cost. If empirical operator feedback indicates the flicker is jarring, follow-up issue collapses to one App with two Screens AFTER re-engineering the stderr-error path."
  - "**Picker Esc/Ctrl-D returns exit 0, NOT a `UsageError`**: when the operator dismisses the picker without choosing, the intent is 'never mind, exit cleanly' — same as Ctrl-D from the main chat pane in INV-002 of issue #4. `_resolve_then_run` returns 0 without calling `create_session` or `App.run_async()` on RatatoskrApp. No session is created server-side; no `agent_id` is required to satisfy this exit path."
  - "**Picker layout uses ListView, not DataTable**: design-brief §5 mentions `DataTable` for the session picker but ListView is the right primitive for agent picking — single-column, keyboard-navigable, one row per agent rendered as `agent_id · name — description`. v1 picker is a flat list per the issue's out-of-scope clause (search/filter/sort/ui_hints rendering all deferred). DataTable's column-header + sortable-column ergonomics are wasted on this surface."
  - "**Empty agent list is a clean exit, not an error**: if `GET /agents` returns `[]`, the picker writes `[no_agents] server returned empty agent list\\n` to stderr and `_resolve_then_run` returns exit code 13 (new — see ERROR_ROUTING below). The picker UI never opens in this case; no point showing an empty list with no actionable rows."
  - "**Single agent does NOT auto-select**: if `GET /agents` returns one agent, the picker still opens with one row. Auto-select would hide the choice (and the agent's description) from the operator. The cost is one keystroke; the benefit is transparency about what's about to happen."
  - "**`AgentInfo` field order in the dataclass matches the spec's column order** (`agent_id`, `name`, `description`, `version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). Mirrors how readers scanning the dataclass map mental model from spec → code."
  - "**`AgentInfo.persona_traits` / `ui_hints` are typed as `dict[str, Any]` not nested dataclasses**: v1 picker just displays `agent_id · name — description`; the inner shape (ocean object, icon, color_hint, vibe) is opaque to ratatoskr. Future polish that renders icon/color_hint would either parse on-demand or introduce nested dataclasses then. Keeping them as dict[str, Any] avoids paying a typing tax now for a display surface that's deferred."
  - "**`list_agents` does NOT pass query params**: spec §832 declares no pagination, no filters. The request is a bare `GET /agents` with the bearer header from the caller-owned client. If Worldtree later adds filters (e.g., `?capability=foo`), `list_agents` gains them via amendment then."
open_questions:
  - "Should the picker display `version` when available (e.g., `mimir v0.2.0 — Keeper of the Well of Knowledge`)? Draft: no for v1 — the issue body specifies `agent_id · name — description` exactly. Add in a follow-up if operators report ambiguity (two `mimir` rows from different deployments). Drift-check evidence first."
  - "Should `AgentPickerApp.run_async()` return the chosen `AgentInfo` or just the `agent_id` string? Draft: just the `agent_id` string for v1 — that's all `create_session` needs. Returning the full `AgentInfo` would let `RatatoskrApp` show name/description in the identity widget without re-fetching, but the existing identity widget format is `<agent_id> · …<session_id>` so the extra metadata has no consumer yet. Defer until §5 side-panes work needs it."
  - "Should the picker show a loading spinner while `list_agents` is in flight? Draft: no for v1 — list_agents runs BEFORE the picker App opens (per the architectural decision above), so there's no in-app loading state to show. Operator sees stderr label on failure; on success the picker opens with the list already populated. If the request latency turns out to be noticeable (e.g., >300ms), reconsider."
prd:
  issue: 8
  issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/8"
  body_sha256_16: "c34f4878936a4edc"
  lock_in_comment_id: null
  lock_in_sha256_16: null
  lock_in_at: null
  pinned_at: "2026-05-24T00:49:03+00:00"
dependencies:
  - issue: 2
    path: "src/ratatoskr/sessions.py"
    reason: "In-place contract amendment: new `list_agents()` function + new `AgentInfo` frozen dataclass added to the module's public surface. POST /sessions paths unchanged; no shared types with the new code beyond the module's existing `SessionApiFailed` exception (reused for non-200 responses)."
  - issue: 3
    path: "src/ratatoskr/cli.py"
    reason: "In-place contract amendment: `_parse_args` STEP 3 splits the single `if ns.new and not ns.agent` check into two conditionals — `--send --new` keeps the strict requirement; bare `--new` accepts `agent_id=None` for the TUI-picker case."
  - issue: 4
    path: "src/ratatoskr/tui.py"
    reason: "In-place contract amendment: new `AgentPickerApp(App[str | None])` class with ListView + Enter/Esc bindings; `_resolve_then_run` gains a pre-create branch that runs the picker when `args.new and args.agent_id is None`; on chosen `agent_id`, threads it into `create_session(client, chosen, end_user_id=args.end_user_id)`. RatatoskrApp itself is unchanged."
---

# Startup agent picker — GET /agents when --new without --agent (TUI)

## Context

Today, `ratatoskr --new` requires `--agent <id>`. If omitted, `_parse_args`
raises `UsageError("--agent is required when --new is passed")`. That's
correct for `--send --new` (non-interactive — can't prompt) but wrong
for the TUI (operator may not know which agents are available, would
prefer to pick from a list at startup).

Worldtree's spec §832 exposes `GET /agents`. The endpoint returns a flat
JSON array; required fields are `agent_id`, `name`, `description`;
optional fields (`version`, `capabilities`, `supported_models`,
`persona_traits`, `ui_hints`) follow omit-when-null/empty rules. No
pagination, no filters, no special scope. Worldtree-dev confirmed
2026-05-23: Tier 1 baseline auth covers it.

This issue threads a small surface change through three existing modules
in-place — no new files apart from this contract.

## Data flow

**Input:**
- `httpx.AsyncClient` (caller-owned, base_url + bearer auth on the client).
- No request body, no query params.

**Output (`list_agents`):**
- `list[AgentInfo]` — one entry per available agent, in server-declared order.

**Output (`AgentPickerApp.run_async()`):**
- `str | None` — chosen `agent_id`, or `None` on Esc/Ctrl-D dismissal.

## Public surface (ratatoskr.sessions amendment)

```python
@dataclass(frozen=True)
class AgentInfo:
    """One agent's metadata from GET /agents.

    INV-005: Required fields (`agent_id`, `name`, `description`) take the
    response value verbatim. Optional fields default to None (`version`)
    or an empty container (`capabilities`, `supported_models`,
    `persona_traits`, `ui_hints`) when omitted from the server response,
    mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern.
    """

    agent_id: str
    name: str
    description: str
    version: str | None
    capabilities: list[str]
    supported_models: list[str]
    persona_traits: dict[str, Any]
    ui_hints: dict[str, Any]


async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
    """GET /agents → list of available agents. See contract FN list_agents."""
```

## Functions

### FN list_agents

```
FN list_agents(client: httpx.AsyncClient) -> list[AgentInfo]
BRIEF: GET /agents → list of available agents. No pagination, no filters.

PRE-001: client is not None.

STEPS:
  1. resp = await client.get("/agents")
  2. IF resp.status_code != 200:
       raise SessionApiFailed(status=resp.status_code, body=resp.content)
  3. body = resp.json()  # expected: list[dict]
  4. items = [
       AgentInfo(
         agent_id      = item["agent_id"],
         name          = item["name"],
         description   = item["description"],
         version       = item.get("version"),
         capabilities  = item.get("capabilities") or [],
         supported_models = item.get("supported_models") or [],
         persona_traits   = item.get("persona_traits") or {},
         ui_hints         = item.get("ui_hints") or {},
       )
       for item in body
     ]
  5. RETURN items

POST-001: every item in the return list has required fields populated.
POST-002: optional fields default to None / [] / {} when absent from the response.

ERROR_ROUTING:
  - 200 with non-list body          → KeyError / TypeError propagates (server bug; not handled here).
  - non-200                         → SessionApiFailed(status=N, body=resp.content)
  - httpx.RequestError              → propagates (network failure; caller handles).

TESTS:
  - test_happy_full_shape: 200 + spec's full-shape mimir example → AgentInfo with all fields populated.
  - test_happy_minimum_shape: 200 + spec's minimum-shape "minimal" example → AgentInfo with required + defaulted optional.
  - test_happy_multi_agent: 200 + array of 3 agents → list of 3 AgentInfo in order.
  - test_happy_empty: 200 + [] → empty list (no error).
  - test_omit_capabilities: 200 + agent missing capabilities → AgentInfo.capabilities == [].
  - test_omit_persona_traits: 200 + agent missing persona_traits → AgentInfo.persona_traits == {}.
  - test_omit_ui_hints: 200 + agent missing ui_hints → AgentInfo.ui_hints == {}.
  - test_500_raises: 500 → SessionApiFailed with status=500.
  - test_401_raises: 401 → SessionApiFailed with status=401.
```

## CLI surface change (ratatoskr.cli amendment)

`_parse_args` STEP 3, currently:

```python
if ns.new and not ns.agent:
    raise UsageError("--agent is required when --new is passed")
```

becomes:

```python
if ns.new and not ns.agent:
    if ns.send is not None:
        # --send --new mode: non-interactive, cannot prompt for choice.
        raise UsageError("--agent is required with --new in --send mode")
    # else: bare --new (TUI mode) — agent_id stays None, TUI runs picker.
```

INV-004 (`--session` + `--agent` mutual exclusion) is unchanged. Existing
`--send --new` smoke flows that pass `--agent` continue to work unchanged.

ERROR_ROUTING (cli):
- `--send --new` without `--agent` → `UsageError` → exit 10 (unchanged from today).
- bare `--new` without `--agent` → `ParsedArgs.agent_id=None`, TUI handles.

TESTS (additions to test_cli.py):
- test_parse_send_new_without_agent_raises: `--send "hi" --new` (no --agent) → UsageError.
- test_parse_bare_new_without_agent_accepted: `--new` (no --agent, no --send) → ParsedArgs with agent_id=None.
- test_parse_bare_new_with_agent_accepted: `--new --agent mimir` → unchanged behavior, agent_id="mimir".

## TUI surface change (ratatoskr.tui amendment)

### New: AgentPickerApp

```python
class AgentPickerApp(App[str | None]):
    """Single-purpose picker App. Opens before RatatoskrApp.

    `run_async()` returns the chosen agent_id (str) or None on Esc/Ctrl-D.
    """

    BINDINGS: ClassVar[list[Binding]] = [
        Binding("enter", "pick", "Pick", priority=True),
        Binding("escape", "dismiss", "Cancel", priority=True),
        Binding("ctrl+d", "dismiss", "Cancel", priority=True),
        Binding("ctrl+c", "dismiss", "Cancel", priority=True),
    ]

    def __init__(self, agents: list[AgentInfo]) -> None:
        super().__init__()
        assert agents  # PRE-002 — caller guarantees non-empty
        self.agents = agents

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("Pick an agent for the new session:", id="picker-prompt")
        yield ListView(
            *[
                ListItem(Label(f"{a.agent_id} · {a.name} — {a.description}"))
                for a in self.agents
            ],
            id="agent-list",
        )
        yield Footer()

    async def on_mount(self) -> None:
        self.query_one("#agent-list", ListView).focus()

    def action_pick(self) -> None:
        lv = self.query_one("#agent-list", ListView)
        idx = lv.index
        if idx is None:
            return  # no row highlighted; ignore
        self.exit(self.agents[idx].agent_id)

    def action_dismiss(self) -> None:
        self.exit(None)
```

### Modified: _resolve_then_run

Insert a pre-create branch between the `async with httpx.AsyncClient(...)`
and the existing `if args.new:` block:

```python
async def _resolve_then_run(args: ParsedArgs) -> int:
    assert isinstance(args, ParsedArgs) and args.send_content is None
    async with httpx.AsyncClient(...) as client:
        # NEW (issue #8): startup agent picker when --new without --agent.
        chosen_agent_id: str | None = args.agent_id
        if args.new and args.agent_id is None:
            try:
                agents = await list_agents(client)
            except SessionApiFailed as exc:
                sys.stderr.write(
                    f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
                )
                return 20
            except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
                sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
                return 21
            if not agents:
                sys.stderr.write("[no_agents] server returned empty agent list\n")
                return 13
            picker = AgentPickerApp(agents)
            chosen_agent_id = await picker.run_async()
            if chosen_agent_id is None:
                return 0  # Esc/Ctrl-D — clean exit, no session created
        # EXISTING: create_session OR re-use --session id
        if args.new:
            assert chosen_agent_id is not None
            try:
                info = await create_session(
                    client, chosen_agent_id, end_user_id=args.end_user_id
                )
            ...
        else:
            ...
        app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
        ...
```

### ERROR_ROUTING (tui startup)

| Failure | Stderr label | Exit |
|---------|--------------|------|
| `list_agents` → `SessionApiFailed` | `[session_api_failed] status=N body=B` | 20 |
| `list_agents` → network error | `[network_error] T: M` | 21 |
| empty agent list (`GET /agents` returns `[]`) | `[no_agents] server returned empty agent list` | 13 (new) |
| picker Esc/Ctrl-D dismissal | (none — clean exit) | 0 |
| `create_session` post-pick → `AgentNotFound` | `[agent_not_found] agent_id=X` | 12 (unchanged from #4) |

INV: exit code 13 is new (no-agents). Previously unused — does not collide
with any existing exit code in `_resolve_then_run` or `_amain`.

### TESTS (additions to test_tui.py)

```
- test_picker_happy_path: list_agents returns 2 agents; AgentPickerApp opens; user picks index 0; chosen agent_id flows into create_session; main App opens.
- test_picker_esc_clean_exit: list_agents returns 2 agents; picker opens; user presses Esc; _resolve_then_run returns 0; create_session NOT called; RatatoskrApp NOT opened.
- test_picker_skipped_when_agent_id_provided: bare --new --agent mimir → list_agents NOT called; picker NOT opened; create_session called with "mimir".
- test_picker_skipped_when_session_mode: --session s-1 → list_agents NOT called; picker NOT opened; no create_session.
- test_picker_list_agents_session_api_failed: list_agents raises SessionApiFailed → stderr [session_api_failed]; exit 20; picker NOT opened; create_session NOT called.
- test_picker_list_agents_network_error: list_agents raises ConnectError → stderr [network_error]; exit 21.
- test_picker_empty_list: list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened; create_session NOT called.
- test_agent_picker_app_renders_rows: AgentPickerApp with 3 agents → ListView has 3 ListItem children with expected text.
- test_agent_picker_app_pick_returns_agent_id: simulate Enter on highlighted row → exit value == agents[idx].agent_id.
- test_agent_picker_app_dismiss_returns_none: simulate Esc → exit value is None.
```

## Invariants

- **INV-005**: `AgentInfo` field defaults are origin-conditional (mirrors INV-001/002 from #2). Required → verbatim; optional → None / [] / {}.
- **INV-006**: `list_agents` failure modes route through `SessionApiFailed` only — no new exception type introduced.
- **INV-007**: Picker is a separate App, opened by `_resolve_then_run` BEFORE `RatatoskrApp`. Preserves issue #6's stderr-error invariant for `list_agents` failures.
- **INV-008**: `--agent` CLI requirement is mode-conditional: required only when `--send --new`; bare `--new` accepts None; `--session` always forbids it.
- **INV-009**: Picker dismissal (Esc/Ctrl-D) returns clean exit 0; no session created server-side.
- **INV-010**: Empty agent list is a clean stderr exit (code 13), not an open picker.
- **INV-011**: Single-agent response still opens the picker — no auto-select.
- **INV-012**: Two alt-screen cycles (picker + main App) is the explicit architectural tradeoff for preserving INV-007.
