Metadata-Version: 2.4
Name: ai-agent-gateway-cli
Version: 0.8.17
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[anthropic]==0.31.0
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. A turn-busy 409 is not retried: the
chat command surfaces it (`TurnAlreadyRunning`). The cancel post is bounded
only by a caller's own timeout. The returned session
carries `elapsed_s` 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.

Automated callers of either transport can pass `resume_after_approval=True`
to `stream_turn`: each approval park is yielded, then the transport re-attaches
through `/chat/subscribe` using the last delivered `seq`, until a non-approval
terminal. The caller still submits every approval vote; the transport does not
approve tools. This opt-in is client-side only and does not change message CLI
behavior or send a new user turn. Continuation events must keep the run's
monotonic sequence so the cursor includes every later approval and terminal.

## hank tool

Execute one admitted operation without a model turn, using its registered name:
`hank tool <tool_name>`. There is no `tool run` wrapper, domain prefix, kebab
alias or second catalog. FMS tools use their registered `local_tool_name`;
FMS's separate process spelling and exit codes are unchanged.

```bash
hank tool ls --json
hank tool run_optimization --schema --json
hank tool run_optimization --example --json
hank tool run_optimization --args '{"portfolio_name":"owned","optimization_type":"max_sharpe","format":"full"}' --json
hank tool run_whatif --args @whatif.json --json
cat backtest.json | hank tool run_backtest --args - --json
cat backtest.json | hank tool run_backtest --args @- --json
hank tool <tool_name> --help
```

Execution requires exactly one `--args` source containing a JSON object: inline
JSON, `@file`, or `-` / `@-` for stdin. Repeating `--args` is an input error,
not a merge. Schema-derived scalar overrides are not yet supported: `--args` is
the input; the client supplies no financial defaults.

`ls` reads the caller's full `tools` array from `GET /api/jobs/capabilities`,
grouping the default display by `owner` with each tool's `name`, `effect_class`,
`dry_run` and `loadable`. `ls --json` prints the raw `tools` array, including
loadable tools, without loading packs or changing admission. `--schema` prints
the named tool's `input_schema`; `--example` prints its declared argv array, or
reports `the owner declares no example` on stderr with exit 1 when it is null.
Both accept `--json`. These reads require the jobs capabilities route, **served
once the jobs capabilities route is mounted**. If it is not mounted (404), the
diagnostic names the unavailable URL and `next_call: hank capabilities --json`.

`--dry-run` sets `input.dry_run=true` and sends it; the tool door owns the
dry-run declaration (it reads the owner's input schema), so a tool whose owner
declares no `dry_run` answers with the owner's typed refusal (exit 2) and a read
tool reports dry-run as not applicable in `capabilities`. The CLI never reads
capabilities on the dry-run path and never refuses on the owner's behalf.
Discovery and inspection take no `--args` or `--dry-run`.

The command resolves the same config namespace, gateway credential, route prefix
and named gateway session as chat, initializes or reuses its bearer, and posts
`{"name":"<tool_name>","input":{...}}` to `/api/chat/tool`. `--session <name>`
selects the session (default `default`); this preserves session-local `load_tools`
grants while the gateway session remains live. Connection overrides
`--base-url`, `--api-key`, `--user-id` and loopback-only `--insecure` are supported.
The terminal holds no Risk service key or browser cookie.

Stdout is data only. Execution prints the complete response object, retaining
owner error fields (`type`, `message`, `state_unchanged`, `next_call`,
`safe_alternative`) unchanged. `--json` or non-TTY stdout emits a single JSON
line with Unicode preserved and one trailing newline; a TTY pretty-prints the
same object, with no prose, colors, prompts or pager. Diagnostics go only to
stderr. Discovery and inspection use the same JSON rendering rules.

Execution projects `meta.exit_code` unchanged, even on a non-2xx HTTP response.
Missing or malformed exit metadata is an owner-protocol error (exit 1, with the
owner named on stderr), never an exit guessed from HTTP status.

| Exit | Meaning |
| --- | --- |
| 0 | Success. |
| 1 | Owner/provider operational failure, HTTP/transport failure, or malformed owner response. |
| 2 | Bad input or integrity refusal, from the local `--args` entry parse or from the owner. |
| 3 | Approval durably parked under a job, not an in-process wait. |
| 5 | Authority refusal or confirmation required, including a money-boundary call with no job. |
| 124 | Caller wait timed out. |
| 130 | Local interrupt (Ctrl-C). |

The tool door never produces 4 (`external_blockage`) or 6 (stale conflict).
`--timeout-seconds <seconds>` bounds the wait (default 600). Timeout and local
interrupt stop waiting without sending cancellation; the owner may still finish.

## 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.
