Metadata-Version: 2.4
Name: ai-agent-gateway-cli
Version: 0.8.5
Summary: Canonical dev CLI client for ai-agent-gateway
License-Expression: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: ai-agent-gateway==0.24.1
Requires-Dist: httpx>=0.28.1
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: httpx>=0.28.1; extra == "dev"

# ai-agent-gateway-cli

Python client for the `ai-agent-gateway` HTTP/SSE protocol, shipped as the `hank`
entrypoint.

## Usage

```bash
python -m agent_gateway_cli login
python -m agent_gateway_cli chat "hello"
python -m agent_gateway_cli --config-namespace cashnerd login
python -m agent_gateway_cli --config-namespace cashnerd chat --session default "hello"
hank login
hank chat
hank chat "hello"
hank chat --model-key anthropic.claude-sonnet-5 --effort high \
  --model-key-for plan.author=anthropic.claude-opus-5-5 \
  --effort-for plan.author=high "research this company"
hank chat --model-key anthropic.claude-opus-5-5 --save-preference "remember this choice"
hank preset
hank chat --model-preset <published-name> "run this under a published preset"
hank chat --show-thinking "walk me through the margin bridge"
hank replay ~/.local/state/hank/evidence/isolated-gateways/<key>/ --session sess_<id>
hank control health
```

The default config namespace is `agent-gateway` for `python -m agent_gateway_cli`
and `hank` for the `hank` entrypoint, which writes:

- `~/.cache/hank/cli_config.json`
- `~/.cache/hank/sessions/<name>.json`

`--config-namespace cashnerd` writes under `~/.cache/cashnerd/` instead. The
namespace directory and its `sessions/` directory are created mode 0700; the
files inside are 0600.

Each named session file holds a local transcript, which is display and
persistence only, and the gateway session identity: the live session id and
token, and the conversation's durable id (`log_session_id`, the first session's
id), which is set when the first chat POST is accepted — init alone does not
establish a durable conversation, so a session that never sent one re-inits
plainly. Each turn sends only the new user message; the gateway replays the
conversation from its session log. When the stored token is no longer usable
(the session's roughly one-hour TTL has passed, or the gateway restarted), the
CLI opens a new gateway session with `resume_session_id` set to the durable id,
and the conversation continues for a session the caller owns. If the gateway
refuses the resume, the CLI prints a notice and starts a new conversation.
`--new` clears both the transcript and the identity.

`GatewayTransport` and `AsyncGatewayTransport` (`agent_gateway_cli/transport.py`,
with `session_map.SessionMap` for per-conversation identity) are the one Python
owner of the gateway chat client contract — init and resume, token reuse, the 401
rule, SSE frame parsing, re-attaching a dropped stream via `/chat/subscribe` from
the last delivered `seq`, cancel, tool approvals and results — consumed by
`hank chat`, `api/dev/chat_cli.py` and, for frame parsing only, the control
client; the contract they mirror is [the consolidation design record](../../docs/design/gateway-chat-client-consolidation-design-2026-09-29.md).
Every `StreamEnvelope` carries `received_at`, and `stream_turn` yields the
gateway's `:keepalive` as a seq-less `transport_keepalive` envelope that never
moves the reconnect cursor. A `stream_error` is yielded and changes neither when
the turn ends nor how it re-attaches, but a turn that then fails carries the last
`stream_error`'s detail in its error message. A chat stream that sends no byte
for `STREAM_IDLE_SECONDS` (60, four missed keepalives; the transports'
`stream_idle_seconds` option) is a dead transport: the read times out and the
turn re-attaches through `/chat/subscribe` like a dropped connection; the server
turn is never cancelled.

No client wait is a minted cap. `/chat/init` and the non-stream posts have no
request timeout, and a turn re-attaches for as long as the server still holds
it, repeating the last reconnect delay. The turn-busy 409 loop keeps retrying.
The cancel post is bounded only by a caller's own timeout. The returned session
carries `elapsed_s`, `conflict_retries` and `reconnects`, the cancel returns
`cancel_outcome` and `cancel_elapsed_s`, and a transport failure names its cause
and the seconds it waited.

Async callers implement `AsyncSessionHolder`; `MemorySessionHolder` and
`SessionMap.holder(key)` supply it. Same-key holders share live identity and
coordinate init with POST acceptance, releasing the lock before SSE reading.
The streaming holder still pins its own session for approvals.
`cancel_chat`, `submit_approval` and `submit_tool_result` take the holder and
post on the in-flight session the caller passes; a dead-bearer 401 refreshes the
holder for the next turn and the request is never replayed on the new token.
`SessionMap.invalidate(key)` retires that conversation for every old holder;
delayed init or POST completion cannot save it over the key's new identity.

`hank chat` with no message on an interactive terminal replaces the process with
the `agent-gateway-tui` binary (installed with
`npm install -g @henrychien/agent-gateway-tui`, whose `engines` field declares
Node >= 24), passing the resolved namespace, session and any
`--research-file-id` as arguments and the resolved connection as
`GATEWAY_BASE_URL`, `GATEWAY_API_KEY`,
`GATEWAY_USER_ID`, and `AGENT_GATEWAY_ROUTE_PREFIX`. The terminal reads no
per-turn selector, so `--skill`, `--mode`, `--model-key`, `--effort`,
`--model-key-for`, `--effort-for`, `--model-preset`, `--save-preference`,
`--clear-preference`, `--raw`, `--drain-trailing` and `--new` given there are named in one stderr note
and the terminal launches; they apply to the message form. `hank chat "<message>"` and
any non-interactive stdout run the Python client in this package instead.

`hank replay <path>` opens a retained chat transcript in the same terminal binary
without connecting to a gateway: `<path>` is a `sess_*.jsonl` file, a directory
holding them, an isolated instance's records root (`state/logs/chat/`) or a
checkout (`api/chat_logs/`), searched in that order. `--session <sess_id>` picks
one transcript, otherwise the newest by mtime; the chosen file is printed on
stderr and passed as `agent-gateway-tui --config-namespace <ns> --replay <file>`.

The Python client prints streamed thinking only with `--show-thinking`: the
model's summarized thinking appears dimmed on a `[thinking]` line ahead of the
`[text]` reply. Without the flag, thinking deltas go to the debug log only. The
TUI shows thinking by default and needs no flag.

When the provider refuses a turn (a `refusal` event), the Python client keeps the
text already delivered, prints `[refusal] <category>: <explanation>` and
`[refusal] <guidance>` after it, and exits 0; nothing is reset, and the guidance
is to start a fresh conversation before continuing.

When the gateway reports a frame it could not deliver (a `stream_error` event),
both the Python message CLI and `api/dev/chat_cli.py` print
`[stream_error] <error>` where it arrived and keep reading; the turn ends on its
own terminal, and the exit code follows that terminal.

Both the Python message CLI and `api/dev/chat_cli.py` stop cleanly (exit 0) on
`stream_complete` with `terminal_disposition: "awaiting_approval"`. They show a
neutral parked notice with `approval_id`, `job_id`, and `root_turn_id` when
present, plus usage, without re-prompting or continuing automatically. The
attempt remains open in the gateway's durable log; like an interrupted turn,
the packaged CLI does not append it to local history. Completed turns alone
commit local history; unknown terminal dispositions remain protocol errors.

## Contract

The CLI sends `context.channel = "cli"` on every chat request. When `user_id`
is configured, it is sent as a top-level field on both `/api/chat/init` and
`/api/chat`. Tool approvals post `tool_call_id`, `nonce`, `approved`, and
`allow_tool_type`.

The CLI consumes authenticated `capability_choices` from `/api/chat/init`.
`--model-key` and `--effort` are sent as given; the gateway judges them against
those choices and refuses an ineligible key or unsupported effort. When both are
omitted, the chat request omits all selection fields and the server resolves the
capability default. Repeatable `--model-key-for CAPABILITY=MODEL_KEY` and
optional `--effort-for CAPABILITY=LEVEL` flags populate init-time run overrides;
the capability id is sent as given and the gateway owns which capabilities are
selectable. Provider-qualified selectors, upstream model names, display-label
aliases, and local fallback catalogs are not accepted.

`--save-preference` and `--clear-preference` use the authenticated server
preference contract for account-wide `session.driver` state; the server refuses
a save with no `--model-key`. Local transcript files do not store or resolve
model preferences.

`hank preset` lists the names the model-selection policy publishes and your
selection, `hank preset <name>` selects one of those names account-wide, and
`hank preset --clear` clears it, through the gateway's `/model-presets/self`
door, which owns which names exist. The shipped policy publishes none today,
so the list is empty and any name is refused. Concrete `--model-key`/`--effort`
selection is unchanged.

`hank chat --model-preset NAME` sends `model_preset` on `/api/chat/init` (only
when given): the session runs under that published preset, above your saved
preset and your firm's, for every turn on it; roles the preset omits come from
policy. The gateway refuses a name its policy does not publish before any turn
runs. A stored session token is not reused when the flag is given; the CLI
re-inits, resuming the stored conversation.

`--research-file-id <n>` attaches `context.research_file_id` to every chat
turn (interactive and message form) and binds a `control dispatch` run; the
gateway's research preflight judges the id.

Every gateway request is composed as `base_url + route_prefix + path`, so the
hosted deployment is reached as `https://hank.investments/gateway/api/chat/init`.
The default `BaseUrlPolicy` accepts any hostname over `https`, allows `http`
only for a loopback host, and rejects a query or fragment. A base URL may carry
the deployment path: it is hoisted out of the base URL into the route prefix, so
`--base-url https://hank.investments/gateway` resolves to base URL
`https://hank.investments` with route prefix `/gateway/api`, identical to
`--base-url https://hank.investments --route-prefix /gateway/api`. An explicit
`--route-prefix` (or `AGENT_GATEWAY_ROUTE_PREFIX`) that neither equals nor
extends the base URL path is refused, naming both values. TLS
verification is on by default; `--insecure` and `HANK_INSECURE=1` are honored
only for a loopback gateway, and `HANK_CA_BUNDLE` selects a custom CA bundle.
Products that need a narrower policy can inject
`BaseUrlPolicy(allowed_schemes=..., allowed_hostnames=...)`.

## Control plane

Chat and control use separate credentials and separate channels. `hank login
--control-api-key <key> [--control-channel <channel>]` stores the control key in
the same namespace config file. Chat requests always send
`context.channel = "cli"`; control sessions default to `--control-channel tui`
(cron/scripts convention sets `AGENT_CONTROL_CHANNEL=cli` instead). Whichever
channel is used MUST match the channel of the `GATEWAY_USER_KEYS` entry the
control key belongs to, or session mint fails with `ChannelMismatch`.

`hank control <subcommand>` drives `/api/control/*`: `dispatch` starts an
autonomous run (`--profile analyst --mode once|skill … [--follow]`); `runs`,
`run`, `logs`, `watch`, `cancel` observe and steer it; `approvals`/`approve`/
`deny`, `batches`, `artifacts`, and `schedules` cover the rest. Run listings are
owner-and-channel scoped: `hank control runs` shows only runs started on the
control key's channel (documented in `docs/reference/agent-run-evaluation.md`).

`--follow` follows a run until it settles; nothing cancels it on a clock.
`--timeout-seconds` is an optional caller bound: reaching it stops following
and prints `elapsed_s` with the `hank control watch <run>` next call, and the
run keeps going. Ctrl-C cancels that exact run (or batch) and waits for its
terminal receipt; a second Ctrl-C exits at once with `cancel_outcome: unknown`.
Receipts and batch results carry `elapsed_s`, `reconnects`, `cancel_elapsed_s`
and `cancel_outcome`, and control object results carry `elapsed_s`.

`hank control approvals` lists pending approvals and `approve`/`deny` resolve
them; there is no standing approval policy to read or set from the CLI.
