Metadata-Version: 2.5
Name: healthcloud-cli
Version: 3.9.0
Summary: Command-line interface for the HealthCloud MCP gateway, generated from the shared MCP tool manifest.
Project-URL: Issues, https://github.com/healthcloudservices/healthcloud-services/issues
Project-URL: Repository, https://github.com/healthcloudservices/healthcloud-services
Author: Healthcheck Systems Inc
License-Expression: MIT
Keywords: cli,health-cloud,healthcare,healthcloud,mcp
Requires-Python: >=3.14
Requires-Dist: click>=8.1
Requires-Dist: healthcloud-sdk>=3.14.0
Requires-Dist: healthcloud-tenantadmin>=1.1.1
Provides-Extra: dev
Requires-Dist: pytest>=9.0.3; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Description-Content-Type: text/markdown

# healthcloud-cli

Requires **Python 3.14 or newer**.

`hc` is an SDK-backed CLI: the primary surface is `hc ams`, which reads and
tunes one explicitly selected AMS tenant (catalog domains -- skills, policies,
guardrails, teams, departments, organization, data-sources, health-services,
tools -- plus
read-only agent operations). The `hc mcp` subtree is the gateway fallback and
has exactly one subcommand per
MCP tool -- generated dynamically at startup from a shared manifest across
audiences such as `patient`, `fieldagent`, `ehr`, `vitals`, `diagnostics`,
`appointments`, `graph`, `person`, `provider`, `tenantadmin`, `connectors`,
and `telehealth`. Run `hc mcp tools list` to see the exact, current set for
your installed version. Nothing here is hand-written per tool: the same
manifest also drives the sibling NPM CLI (`@healthcloud CLI` in
`packages/cli/npm`), so command names match exactly between the two.

## Install

```bash
pip install healthcloud-cli
```

This installs the `hc` console script plus its dependency, the
[`healthcloud-sdk`](../../pip) package, which does the actual HTTP/JSON-RPC
work against the MCP gateway.

## Local development

Use uv 0.12.5 and Python 3.14. The package's uv source override installs the
SDK checkout in editable mode; its committed lock includes the test and build
tools. Released package consumers can still use pip.

```bash
cd packages/cli/pip
uv lock --check
uv sync --frozen --extra dev
uv run --frozen --extra dev python -m pytest
uv run --frozen hc --help
uv run --frozen hc mcp tools list --audience patient
uv run --frozen hc mcp patient get-patient --help
```

AMS help and shell-completion discovery are credential-free and offline: they
do not resolve root profiles, read target files, create SDK clients, or log in.
You can inspect the group, a leaf command, and completion candidates before
configuring credentials or a target:

```bash
uv run --frozen hc ams --help
uv run --frozen hc ams inspect --help
_HC_COMPLETE=bash_complete COMP_WORDS="hc ams in" COMP_CWORD=2 uv run --frozen hc
_HC_COMPLETE=bash_complete COMP_WORDS="hc ams inspect --o" COMP_CWORD=3 uv run --frozen hc
```

Operational validation starts only when Click invokes an AMS leaf callback.
`discover`, `agents`, `departments`, `teams`, `organization`, `skills`, `policies`, and `sessions`
(with `attachments` and `messages`) then
validate their explicit environment and tenant arguments; the remaining leaves
require `--target-file`. All apply the root, target, and authentication checks
described below. Deferring preflight does not relax those safety requirements.

The bundled tool manifest (`healthcloud_cli/contracts/mcp-tools.manifest.json`)
is a copy of `packages/cli/contracts/mcp-tools.manifest.json`. Re-sync it
after pulling manifest changes with:

```bash
python scripts/bundle_manifest.py
```

This also runs automatically as a hatchling build hook whenever you `python
-m build`, so a built wheel/sdist is always self-contained and never depends
on a relative path outside the installed package.

## Selecting an AMS API URL

Pass `--api-url` after `ams` to select a different AMS deployment without changing
SDK defaults or DNS:

```bash
uv run --frozen hc ams --api-url https://dev-ams-services.health.cloud/api/ams \
  agents list --environment dev --tenant-id careco
```

The value is the full HTTPS service base URL, including `/api/ams` when that is
the service path. A trailing slash is normalized. Without this option, the
existing environment-derived URL is used. The option applies to AMS discovery,
reads and writes; it does not change the tenant-admin login/refresh endpoint or
the saved session's environment and tenant. Root `--base-url` is for MCP and
remains separate. Use root `--verbose` to show the selected override on stderr;
JSON results remain on stdout.

For tuning commands, the target file's `api_origin` must match the selected URL's
origin. New override-based receipts bind the full URL, including its path: repeat
the same option for `show`, `apply` and `restore`. Changing the endpoint or dropping
the option requires a new reviewed plan; existing default receipts remain bound
to their original default configuration. Embedded credentials, query strings,
fragments and ambiguous paths are refused. TLS verification stays enabled and
redirects are not followed.

The local `ui` launcher supports only the `/api/ams` service path because the
existing frontend appends that path itself; it refuses custom API paths before
launching. This option does not update deployed web clients.

## Authentication & configuration

Every command accepts these global options (place them before the `mcp`
subcommand):

| Flag              | Env var           | Meaning                                      |
|-------------------|--------------------|-----------------------------------------------|
| `--access-token`  | `HC_ACCESS_TOKEN`  | Bearer token sent to the MCP gateway          |
| `--base-url`      | `HC_MCP_BASE_URL`  | Override the MCP gateway base URL             |
| `--profile`       | `HC_PROFILE`       | Named profile (see below)                     |
| `--output`        | --                  | `pretty` (default) \| `json` \| `raw`         |
| `--raw`           | --                  | Shortcut for `--output raw`                   |
| `--verbose`       | --                  | Print request diagnostics to stderr           |

Precedence, highest wins: **explicit CLI flag > environment variable >
profile file value > built-in default** (`environment` defaults to `"dev"`;
`accessToken`/`baseUrl` default to unset). The resolved access token is never
written to logs, generated files, or test snapshots.

### Saved `hc login` session fallback

When no explicit token is given (`--access-token`, `HC_ACCESS_TOKEN`, or a
profile `accessToken`), `hc mcp` uses the saved `hc login` tenant-admin
session as the bearer, with the same locked refresh-near-expiry behavior as
`hc ams` (the refreshed session is persisted for the next command):

```bash
hc login --environment dev --tenant-id TENANT_ID   # saves/refreshes the session
hc mcp tenantadmin get-current-tenant-admin        # uses the saved session bearer
```

On `HTTP 401` when no explicit token and no saved session exist, the CLI
prints one hint line — `Hint: not logged in; run 'hc login', or pass
--access-token / HC_ACCESS_TOKEN.` — after the failure line, and exits `3`.
An explicit token always wins over the saved session; `hc logout` removes it
(local only).

### Optional profile file

`~/.healthcloud/cli/profiles.json`:

```json
{
  "dev-mia": {
    "baseUrl": "https://dev-api-mcp.health.cloud",
    "accessToken": "eyJ...",
    "environment": "dev"
  }
}
```

A missing profiles file is not an error -- profiles are entirely optional. If
`--profile`/`HC_PROFILE` names a profile and the file (or that entry) is
missing, `hc` exits with a clear usage error rather than silently ignoring
it.

```bash
# get-patient is self-service — the patient is resolved from the access
# token, so no --patient-id flag exists.
hc --profile dev-mia mcp patient get-patient
```

## Listing and inspecting tools

```bash
# Local (bundled manifest, no network call)
hc mcp tools list
hc mcp tools list --audience patient

# Live gateway (requires --audience)
hc mcp tools list --audience patient --remote

# Full manifest entry for one tool (description, inputSchema, method/path)
hc mcp tools show patient add_insurance --output json
```

## Calling a tool

Every manifest tool is its own subcommand, grouped by audience:

```bash
hc mcp patient get-patient
hc mcp appointments book-appointment --patient-id patient-123 --slot-id slot-456

# Every Diagnostics tool takes an explicit --patient-id — providers and
# field agents legitimately look up or record diagnostics for a patient
# other than themselves. When the caller holds a patient token, the backend
# enforces server-side that --patient-id must be their own (403 otherwise).
hc mcp diagnostics create-rapid-test --patient-id patient-123 --test-type covid-19 --result negative
```

Run `--help` on any generated command to see its real manifest description
and arguments:

```bash
hc mcp patient get-patient --help
```

### Argument flags

- **Primitive properties** (`string`/`number`/`integer`/`boolean`) become a
  normal flag in kebab-case, e.g. `patient_id` -> `--patient-id`.
  - `enum` values are validated; invalid values are rejected before any
    network call.
  - `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum` are enforced
    the same way.
  - Boolean properties are a tri-state flag pair: `--flag/--no-flag`.
    Passing neither leaves the value unset (falls back to the manifest
    default or an `--input`/`--stdin` value, if any); passing one sets it
    explicitly to `true`/`false`.
- **Object/array properties are never flattened.** They get a single
  `--<prop>-json` flag that takes a raw JSON string, e.g.:

  ```bash
  # create-medication is self-service — no --patient-id flag; the patient is
  # resolved server-side from the access token.
  hc mcp patient create-medication \
    --name "Metformin" \
    --dosage-json '{"value":10,"unit":"mg","frequency":"once_daily"}'
  ```
- Path parameters (from `{placeholders}` in the tool's HTTP path) are always
  required, in addition to whatever `inputSchema.required` lists.

### Bulk input: `--input` / `--stdin`

```bash
hc mcp appointments book-appointment --input request.json
cat request.json | hc mcp appointments book-appointment --stdin
```

`--input <file>` and `--stdin` both supply a *base* JSON object of
arguments. `--input` and `--stdin` cannot be combined. Merge precedence
(highest wins): **manifest defaults < `--input`/`--stdin` object < explicit
flags** -- i.e. an explicit flag you actually pass always overrides the same
key coming from a file or stdin, which in turn overrides the schema default.

After merging, the final object is validated against the tool's
`inputSchema` (required fields present, correct types, enum membership,
numeric bounds). Any validation failure prints a clear message to stderr and
exits **2**, without making a network call.

### `hc mcp call` -- generic escape hatch

`hc mcp call` dispatches to any MCP tool by its exact snake_case tool name --
including one not yet present in the bundled manifest (the tool name itself
is never validated against the manifest, it's just forwarded):

```bash
# get_patient is self-service and takes no arguments — {} is a complete,
# valid call.
hc mcp call patient get_patient --input '{}'
hc mcp call patient get_patient --input-file request.json
cat request.json | hc mcp call patient get_patient --stdin
```

Note the deliberate inconsistency versus named commands: here `--input` takes
an inline JSON **value**, not a file path -- use `--input-file` for a file.
Exactly one of `--input` / `--input-file` / `--stdin` may be given (or none,
meaning `{}`).

## Output modes

The MCP tool-call result is `{ content: [{type:"text", text: "..."}],
is_error: bool }` from the JSON-RPC response's `.result`, or the JSON-RPC
`.error` object if the gateway returned one.

- **`pretty`** (default): if `content[0].text` parses as JSON, pretty-prints
  the parsed value; otherwise prints the raw text. On a JSON-RPC error,
  prints `error.message` (and `error.code` with `--verbose`) to stderr.
- **`json`**: prints the tool result's parsed JSON (or raw text if
  unparseable) to stdout, with no envelope.
- **`raw`**: prints the complete JSON-RPC envelope (`id`, `jsonrpc`,
  `result`, `error`) as returned by the gateway.

Diagnostics and `--verbose` logs always go to stderr -- stdout stays
pipeable in every mode.

## Exit codes

| Code | Meaning                                                              |
|------|-----------------------------------------------------------------------|
| `0`  | Success                                                                |
| `2`  | Local input validation failure (bad JSON, missing required field, invalid enum/number, `--input`+`--stdin` both given, unknown option) -- no network call made |
| `3`  | HTTP/network failure talking to the MCP gateway                       |
| `4`  | JSON-RPC error response (`.error` is not `None`)                      |
| `5`  | MCP tool result reported `is_error: true`                             |

## Building

```bash
uv lock --check
uv run --frozen python -m build --no-isolation
uv run --frozen python -m zipfile -l dist/*.whl   # confirm the bundled manifest is included
```

## Deployed AMS administration (source checkout)

The reviewed AMS administration SDK is not published yet. Use this checkout's
editable SDK and tenant-admin sources; no released SDK version is claimed to
contain `ams_admin`. An installed SDK missing that capability is refused before
login. Ordinary MCP commands keep their existing behavior.

Prepare ignored SDK build READMEs from tracked inputs, then install the locked
editable packages (the CLI README itself is tracked):

```bash
node tooling/docs-compiler/prepare-package-readmes.mjs
cd packages/cli/pip
uv sync --frozen --extra dev
uv run --frozen hc ams --help
```

Create one private tenant-admin session before interactive AMS work, inspect its
non-secret binding, and remove it when finished:

```bash
uv run --frozen hc login --environment dev --tenant-id TENANT_ID
uv run --frozen hc login --status
uv run --frozen hc logout
```

`hc login` reads `HC_AMS_ADMIN_EMAIL` and `HC_AMS_ADMIN_PASSWORD` when present,
otherwise it prompts (with hidden password input). It stores only the email,
tenant/environment binding, access/refresh tokens and expiry in the strict
owner-only `~/.healthcloud/cli/ams-session.json`; it never stores the password.

By default (`pretty`) these three commands print readable lines — never a raw
JSON blob and never a token:

```text
$ hc login --environment dev --tenant-id TENANT_ID
saved session: dev / TENANT_ID
email: admin@example.test
expires_at: 2026-11-02T01:42:29Z
$ hc logout
saved session removed: true
server session revoked: false
```

`hc login --status` prints the same three lines for the saved session, and a
second `hc logout` reports `saved session removed: false`. `--output json` (and
`--output raw`, which these commands treat as JSON) prints exactly the objects
they have always printed: `{"schema_version": 1, "ok": true, "session":
{"schema_version": 1, "environment", "tenant_id", "email", "expires_at"}}`
for `login`/`--status`, and `{"schema_version": 1, "ok": true, "removed":
true|false, "server_session_revoked": false}` for `logout`.

For CI and agents, log in non-interactively: pass `--email` and pipe the password
to `--password-stdin` (read as exactly one stdin line, never echoed, never a
flag value — there is no `--password` option):

```bash
printf '%s\n' "$HC_AMS_ADMIN_PASSWORD" | \
  uv run --frozen hc login --environment dev --tenant-id TENANT_ID \
  --email "$HC_AMS_ADMIN_EMAIL" --password-stdin
```

`--email` beats `HC_AMS_ADMIN_EMAIL` and `--password-stdin` beats
`HC_AMS_ADMIN_PASSWORD`. With `--password-stdin`, the piped line is always the
password: it is read before any prompt, so it can never be consumed as the
email, and the email must come from `--email`, `HC_AMS_ADMIN_EMAIL` or (only on
an interactive terminal) the email prompt. When stdin is not an interactive
terminal and neither `--password-stdin` nor `HC_AMS_ADMIN_PASSWORD` supplies the
password, `hc login` refuses with that guidance instead of attempting a hidden
prompt (no `GetPassWarning`, no echoed password); a missing email on a
non-interactive stdin (or a process with no stdin stream at all) is refused the
same way, naming `--email`/`--password-stdin`; every refusal happens before any
login request. `--status` cannot be combined with `--email` or `--password-stdin`.
Even on an interactive terminal, `--password-stdin` suppresses terminal echo
while the single line is read and restores it afterwards; a stdin stream that is
closed or errors mid-read is refused cleanly (exit 2, never a traceback).
AMS commands may omit both target flags while this session is present, except
that a `prod` session still needs `--environment prod` on every command. Explicit
flags and target files must match it exactly. A session within 60 seconds of
expiry is refreshed once before the operation, under a lock so concurrent
commands never spend the same refresh token (a second command waits up to 30 seconds, then stops), and
atomically replaced. Refresh
failure stops without an AMS call or password fallback and asks for a new login.
If the refreshed session cannot be saved, the current command still finishes and
prints a warning to run `hc login` again.

`hc login` works before the admin's email is verified. The current-admin
profile reports `email_verified` (invited admins start verified); confirm the
emailed code with `hc tenants verify-email`.

`~/.healthcloud` and `~/.healthcloud/cli` must be directories you own with mode
700; the CLI creates them that way, and refuses (naming the directory and the
`chmod 700` fix) if an existing one is shared. `hc logout` deletes the local file
only and reports `"server_session_revoked": false`: issued tokens stay valid
until they expire.

## Tenant registration: `hc tenants register`

Creates a tenant and its first (primary) tenant admin through the tenant-admin
SDK (`POST /tenants/register`) — CLI-first, never an MCP passthrough:

```bash
printf '%s\n' "$ADMIN_PASSWORD" | \
  uv run --frozen hc tenants register \
    --tenant-id acmeclinic --name "Acme Clinic" \
    --admin-first-name Ana --admin-last-name Doe \
    --admin-email ana@example.test --password-stdin
```

- Requires a saved `hc login` session — the session token is attached to the
  SDK before the call; without a session the command refuses with
  "run `hc login` before registering a tenant" and makes no network call.
  `--environment` must match the saved session's environment.
- `--tenant-id` is validated locally (lowercase a-z only, e.g. `acmeclinic`)
  before any network call; anything else is refused without a request.
- The admin password comes only from a hidden interactive prompt or
  `--password-stdin` (exactly one stdin line, echo-suppressed). There is no
  `--password` flag, and the password is never echoed or logged.
- Output: a `tenants.register` envelope with the tenant ID, status, tier,
  admin ID and `email_otp_sent`, plus a next-step hint pointing at
  `hc tenants verify-email`. A 409 is reported as "A tenant with this ID
  already exists"; other API errors surface the backend's safe message.

## Tenant email verification: `hc tenants verify-email` and `hc tenants resend-code`

Confirms (or re-sends) the registration OTP through the tenant-admin SDK
(`POST /auth/verify-email` / `POST /auth/resend-verification-code`) —
CLI-first, never an MCP passthrough:

```bash
uv run --frozen hc tenants verify-email \
  --environment dev --tenant-id acmeclinic --email ana@example.test --otp 123456
uv run --frozen hc tenants resend-code \
  --environment dev --tenant-id acmeclinic --email ana@example.test
```

- No saved `hc login` session is required: the emailed OTP is the proof, and
  no bearer token is sent.
- `--tenant-id` is validated locally (lowercase a-z only) before any network
  call; `--email` and `--otp` must be nonblank.
- `verify-email` output: a `tenants.verify-email` envelope with the verified
  email and the exact next command to run —
  `hc login --environment dev --tenant-id acmeclinic --email ana@example.test`
  (the password is prompted, never a flag value). `email_verified` is `true`
  only when the service answers `verified: true`; any other answer reports `false`.
- `resend-code` output: a `tenants.resend-code` envelope with `sent: true`.
- A wrong or expired code is reported as the server's safe message (e.g.
  "Verification code is invalid or expired"); an unknown email is a
  not-found error. Both are bounded JSON envelopes, never stack traces.

## Managing tenant admins: `hc admins create`, `hc admins list`, `hc admins get`

Adds and reads a tenant's admins through the tenant-admin SDK
(`POST /auth/register` mode=self, `GET /tenant-admins`, `GET /admins/{admin_id}`)
— CLI-first, never an MCP passthrough:

```bash
printf '%s\n' "$ADMIN_PASSWORD" | \
  uv run --frozen hc admins create --first-name Bo --last-name Nguyen \
    --email bo@example.test --password-stdin
uv run --frozen hc admins list
uv run --frozen hc admins get --admin-id ADMIN_ID
```

- All three commands require a saved `hc login` session; the session's tenant
  is always the target (there is no `--tenant-id` flag). Without a session the
  command refuses with "run `hc login` before managing tenant admins" and
  makes no network call. `--environment` (optional) must match the saved
  session's environment.
- `create` adds another admin to the saved session's tenant. The new admin's
  password comes only from a hidden interactive prompt or `--password-stdin`
  (exactly one stdin line, echo-suppressed); there is no `--password` flag and
  the password is never echoed or logged. Its success envelope carries the
  new `admin_id`, `email_otp_sent` and a next-step hint pointing at
  `hc tenants verify-email`. A 409 is reported as "A tenant admin with this
  email already exists for the tenant".
- `list` and `get` are read-only, unpaged and bounded: each admin is projected
  to `id`, `name`, `email`, `status` and `is_primary` only — never phone
  numbers, Cognito subs, permissions or audit fields. `list` emits
  `data.admins` plus an unpaged `page` block; `get` emits `data.admin`. Both
  omit the `verification` block, like every read.
- Failures are the shared bounded error envelopes: not-found (`get` on an
  unknown ID), conflict (duplicate email on `create`), validation (blank
  inputs or an environment mismatch, refused before any network call),
  authentication (no saved session) and transport — never stack traces.

Bootstrap candidate IDs with the public SDK discovery endpoint. Without a saved
session both flags are required; a saved session supplies both together. No MCP
defaults, admin login, or automatic candidate selection are used:

```bash
uv run --frozen hc ams discover --environment dev --tenant-id TENANT_ID
```

Read-only agent listing, inspection and export take flags instead of a target
file; both may be omitted together when a matching saved session exists. Without
one, they log in as tenant admin using `HC_AMS_ADMIN_EMAIL` /
`HC_AMS_ADMIN_PASSWORD`. They emit the same versioned result envelope as the
other read domains (`operation`, `outcome`, `schema_version`, `target`, `data`;
no `verification` block on reads); failures emit one categorized JSON error
envelope on stderr with the same categories (not found, target mismatch,
refusal, logged out), not the plain-text `Error: …` lines of earlier releases.
Read failures are classified by what the request read, not by its status code
alone. A missing agent is `not_found` whether the server answers HTTP 404 or a
`200` that carries no record at all — a literal `null`, or a body-less `200` or
`204` (the transport returns the same nothing for all three, so the CLI cannot
tell them apart and does not pretend to). A `200` that is something else
entirely (a non-JSON body, or an object without `id`/`tenant_id`) stays
`validation`, because that is a bad response rather than a missing record, and a
body naming a different agent or tenant keeps the "Agent/tenant identity
mismatch" refusal. A 404 on `agents list` is not `not_found` either: a route
that does not serve the collection is a route-level failure and stays
`validation`. `export` re-reads the agent before it exports, so a missing agent
already 404s at that read and is `not_found`; by the time the export request is
sent the agent is known to exist in this tenant, so a 404 from the export
request itself means the build does not serve
`/agents/{agent_id}/configuration/export` (dev today) or the agent has no
exportable configuration. That is reported as an `unsupported` refusal naming
the cause and pointing at `hc ams agents get` — never as `not_found`, and never
as a validation problem with the command's own input. The `--target-file`
refusal is decided before the saved session is resolved, so
`hc ams --target-file <f> agents list|get|export` with no flags says "requires
explicit environment/tenant flags, without a target file" rather than "Not
logged in"; the agents write commands still resolve the session first.
`agents list` carries bounded projections under `data.agents` plus a `page`
block; `agents get` carries the bounded projection under `data.agent`, whose
`assigned_references` holds both halves of an assignment: the nullable
`department_id`/`team_id` organization references and the five capability lists
(`tool_ids`, `skill_ids`, `guardrail_ids`, `policy_ids`, `data_source_ids`). The
legacy top-level `{"agents": …, "environment": …, "tenant_id": …}` shape is
intentionally replaced by this envelope — scripts must read the new shape.
Stdout carries bounded metadata only; `adapter` shows
whether an agent runs from the `file` or `db` config adapter (`unknown` when the
server does not say). `export` first re-checks the agent/tenant identity, then
saves the server's own JSON/YAML export byte-for-byte to a new owner-only `--out`
file (exact bytes preserved), refusing (and leaving no file) when the body is
not valid UTF-8; its stdout reports the envelope with the file `path`, `format`
and `sha256` under `data` and the `agent_id` in `target`. `export`
needs an AMS build that serves `/agents/{agent_id}/configuration/export`; that
route is not yet deployed to dev, where its 404 is reported as the `unsupported`
refusal described above and no file is left behind:

```bash
uv run --frozen hc ams agents list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams agents get --environment dev --tenant-id TENANT_ID --agent-id AGENT_ID
uv run --frozen hc ams agents export --environment dev --tenant-id TENANT_ID --agent-id AGENT_ID \
  --format yaml --out agent.yaml
```

For example, `hc ams agents list` succeeds with (formatted for readability):

```json
{"schema_version": 1, "operation": "agents.list", "outcome": "succeeded",
 "target": {"environment": "dev", "tenant_id": "TENANT_ID"},
 "data": {"agents": [{"id": "AGENT_ID", "name": "…", "adapter": "file", "…": "…"}],
          "page": {"kind": "unpaged", "returned_count": 1, "continuation": null}}}
```

and an agent the tenant does not have — an HTTP 404, or a `200` with a null or
empty body — fails with one envelope on stderr and exit 1:

```json
{"schema_version": 1, "operation": "agents.get", "outcome": "failed",
 "target": {"environment": "dev", "tenant_id": "TENANT_ID", "agent_id": "AGENT_ID"},
 "error": {"category": "not_found", "message": "Agent 'AGENT_ID' was not found."}}
```

Agent lifecycle changes are two first-class, send-once write commands. They
require a saved `hc login` session (the command refuses before any request when
none is saved; env-var credentials are not accepted here) and the `--confirm`
acknowledgement. `--expected-config-version` is optional:

```bash
uv run --frozen hc ams agents deactivate --environment dev --tenant-id TENANT_ID \
  --agent-id AGENT_ID [--expected-config-version N] --confirm
uv run --frozen hc ams agents activate --environment dev --tenant-id TENANT_ID \
  --agent-id AGENT_ID [--expected-config-version N] --confirm
```

Each command issues exactly one `GET /agents/{agent_id}`, local pre-checks,
exactly one `PUT /agents/{agent_id}`, and exactly one `GET` readback. The PUT
body is exactly `{"status": …, "active": …}` and nothing else — the server's
update schema accepts ~40 fields, but a lifecycle command is not a generic CRUD
fallback, so there is no `agents update`, no `--status`/`--active` flag, and no
`archive` here (deletion is its own reviewed command, below). `deactivate` writes `status: "disabled"` **and**
`active: false`; `activate` writes `status: "active"` **and** `active: true`.
Writing one field and reporting the other is a false claim, so both are written
and both are read back.

The result envelope reports three facts separately under `data`:
`data.evidence.saved` (`confirmed` only when the readback shows the exact pair
that was sent, for this agent in this tenant), `data.evidence.side_effects`
(always `unverified`: no side-effect readback route exists) and
`data.evidence.runtime` (always `unverified`: no serving route exists). Exit 0
requires that readback confirmation; a readback mismatch exits nonzero with
`"outcome": "failed"` and `verification.saved_state: "failed"` and says the new
state is **not** confirmed. A readback that is missing, cannot be read as an
agent object, describes a different agent, or lands in a different tenant is the
same unconfirmed failure (never an internal error): `data.eligibility.fields_tested_by_read_paths` is then `null`
because those fields could not be observed. `data.eligibility` states that the
fields the read-eligibility paths test now read the new pair, and warns
explicitly that
`get_default_agent_for_tenant` / `get_orchestrator_for_tenant` never test these
fields — a disabled agent can still be selected by those paths — and that
in-flight runs, WSS sessions and schedules are untouched by this route.

`data.config_version_check` says which concurrent-edit check actually ran:
`matched` (the read `config_version` equals `--expected-config-version`),
`not_run` (the flag was omitted, so **no** version check ran and the reviewed
target plus `--confirm` are the approval), never a silent success. It is carried
on the confirmed, refused **and** unknown envelopes alike, so an outcome where
the write may already have committed still says which check ran. A deployment
whose agent payload omits or nulls `config_version` cannot honour the flag: the
command refuses that flag with a way forward (`Re-run without
--expected-config-version`) instead of refusing every lifecycle command forever
and instead of interpolating a Python `None` repr.

Default `pretty` output prints those facts as labelled lines — `transition`,
`requested`, `config_version_check`, `evidence` (`saved`, `side_effects`,
`runtime`), `eligibility`, `mutation_replayed: false` — and closes with a
`verification:` block, so a
deactivate reads `transition: deactivate`, then
`evidence: / saved: / status: confirmed`, then `verification: / saved_state:
confirmed`, `side_effects: unverified`, `runtime: unverified`; it is never one
raw JSON line. `--output json` prints the full envelope above, byte-identical to
before. An unknown outcome still prints its one-line `error:` plus the
reconciliation on stderr.

Without `--confirm` the command is a **preview**: it re-reads the agent exactly
once (the same one bounded `GET`), runs the same local pre-checks, prints
`preview: --confirm was not passed, so nothing has been sent.` and then the
resolved change to stderr — the target (`agent_id`, `name`, `type`,
`config_version`), `status` and `active` before -> after, and
`one PUT /agents/{agent_id} with body keys: status, active` — and exits 2 having
sent nothing. With `--confirm` the write path is unchanged: it re-reads before
the single `PUT`, so a preview is never an approval token. The preview text is
suppressed under `--output json`/`raw` (the same rule the confirmed path's
review follows) so those modes keep stderr machine-parsable.

Pre-checks refuse with exit 1 and **no write**: an agent outside the explicit
tenant, an already in-effect state, an unsupported `archived`/`draft` target
(named as having no supported reversible transition here), or a
`config_version` that no longer matches `--expected-config-version` — all of
these are also enforced by the preview, which still sends nothing. A missing
`--confirm` exits 2. An `active` value the server emits as `1`/`0`/`"true"`/
`"false"` is interpreted as a flag (an already in-effect state is still
recognised and a correct write is still confirmed); any other value is
unconfirmed, never a false success. That version check is a local best-effort
guard only: the route has no `If-Match` and the server never compares the
version, so it cannot prove atomicity and a request could still race between
the read and the PUT.

A transport error, a `5xx`/`408` from the PUT, or an interrupt (`Ctrl-C`) during
the PUT or the readback is reported as `outcome: "unknown"` with
`mutation_replayed: false`, `verification.saved_state: "unknown"` and a
`reconciliation: {"status": "not_read", …}` block; the CLI sends no retry and
does not re-read automatically — run `hc ams agents get` before doing anything
else. A failed readback after an accepted PUT is reported the same way (unknown,
not success). A `2xx` PUT answer whose body cannot be read as an agent object is
also `outcome: "unknown"` (attempted once; the request may not have been sent
and its saved state is unverified), never an internal error and never a
success.

A non-`5xx` rejection of the PUT (`409`, `422`, `400`, `403`, …) is worded as a
**write** that the server refused, never as a read: `outcome: "failed"`,
`verification.saved_state: "failed"`, `mutation_replayed: false`, the pre-write
state under `data.evidence.saved.before`, and a `reconciliation` saying the CLI
did not re-read. A `2xx` body that is not an agent object at all (for example a
plain-text body) is a bounded `validation` refusal with the same
`verification: not_applicable` as any read, never an `AttributeError` and never a
traceback.

The saved-session requirement above is enforced at the command boundary: the
shared AMS authentication path still has the `HC_AMS_ADMIN_EMAIL`/
`HC_AMS_ADMIN_PASSWORD` tenant-admin fallback that every AMS command group uses,
so the guarantee is "these commands refuse without a saved session", not "no AMS
write anywhere can reach the server on env-var credentials".

Capability and organization assignments are one first-class, send-once write
command. It requires a saved `hc login` session (the command refuses before any
request when none is saved; env-var credentials are not accepted here), an
explicit `--expected-config-version`, and the `--confirm` acknowledgement of the
target and the printed reviewed diff:

```bash
uv run --frozen hc ams agents assignments set --environment dev --tenant-id TENANT_ID \
  --agent-id AGENT_ID --expected-config-version N --confirm \
  --tool-ids TOOL_ID --tool-ids ANOTHER_TOOL_ID \
  --clear-team
```

There is **no** assignment route on the server: assignments are plain fields on
the one `PUT /agents/{agent_id}` route, so this command issues exactly one
`GET /agents/{agent_id}`, local pre-checks, exactly one `PUT`, and exactly one
`GET` readback, and the PUT body is **exactly** the reference fields the
reviewed diff changes — never the whole agent payload, never a generic
`agents update`, never the legacy `tools`/`data_sources` name aliases, and never
a lifecycle, model or instruction field.

Each family is either omitted (unchanged) or given exactly once:
`--department-id`, `--team-id`, and the repeatable `--skill-ids`, `--tool-ids`,
`--policy-ids`, `--guardrail-ids`, `--data-source-ids` flags replace that family
exactly; `--clear-department`, `--clear-team` and `--clear-skill-ids`,
`--clear-tool-ids`, `--clear-policy-ids`, `--clear-guardrail-ids`,
`--clear-data-source-ids` empty or clear it (`[]` for a list family, `null` for
the two nullable scalars). The server de-duplicates ids, so the CLI does too.
`health_service_id`, `custom_tools`, `artifact_kinds`, lifecycle and `workflow`
are not part of this command: a health-service assignment has no `hc` read
surface, so it would not be reviewable.

The reviewed diff is printed to stderr before the single write (ids only, never
instruction text): each changed field with its `before` -> `after` value, plus
sorted `added`/`removed` id lists for the list families. It is omitted under
`--output json`/`raw` so those modes keep stderr machine-parsable; the same diff
is carried in the envelope under `data.changed` as
`{field: {before, after, added, removed}}` (`added`/`removed` are `null` for the
two scalar references).

The envelope reports `data.agent_id`, `data.changed`,
`data.config_version: {before, after}` (a version advance is informational
only), and three facts separately under `data.evidence`: `saved` (`confirmed`
only when the readback is this agent in this tenant and **every** changed
reference equals the intended after value), `side_effects` (always `unverified`)
and `runtime` (always `unverified`). `data.mutation_replayed` is always `false`.
The three side effects are disclosed, not verified: this route does **not**
regenerate or publish the agent's Application file (only the plan-apply path
publishes), `disable_direct_agent_mutations` does not gate this `PUT`, and the
server audit log records an `UPDATE` with the payload. Runtime enforcement of
the new references is unobservable: no serving readback route exists, so this
command never claims it.

Default `pretty` output prints the same facts as labelled lines: `agent_id`,
`changed` (each field with `before`/`after`/`added`/`removed`),
`config_version: {before, after}`, `evidence` (`saved`, `side_effects`,
`runtime`), `mutation_replayed: false`, and a closing `verification:` block.
`--output json` prints the full envelope described here, unchanged.

`tool_ids` and `data_source_ids` address **unscoped global catalogs** by design;
the server checks tenant scope only for the department, team, skill, policy and
guardrail references. This command does not imply otherwise, and it does not
re-implement the server's reference checks.

Without `--confirm` the command is a **preview**: it reads the agent exactly once
(the same one bounded `GET`), computes the same reviewed diff and runs the same
local pre-checks, prints `preview: --confirm was not passed, so nothing has been
sent.` followed by the reviewed diff, and exits 2 having sent no `PUT`. With
`--confirm` the write path is unchanged: it re-reads before the single `PUT`, so
a preview is never an approval token. Both renderings stay human: the preview
text is suppressed under `--output json`/`raw` because it accompanies a usage
refusal (which prints no envelope), and the **confirmed** review is suppressed
the same way, like the lifecycle review — in those modes the reviewed diff is
carried in the envelope under `data.changed` instead, so a scripted caller reads
the diff from the envelope and never from stderr. `agents delete` is the one
exception: its confirmed review prints in every mode, because it is the
acknowledgement of an irreversible hard delete rather than a reversible field
write.

Pre-checks refuse with exit 1 and **no write**: no assignment option at all
(exit 2), setting and clearing the same family together (exit 2), an agent
outside the explicit tenant, a request where every reference already equals the
read state ("already in effect"), or a `config_version` that no longer matches
`--expected-config-version` — all of these are also enforced by the preview,
which still sends nothing. A missing `--confirm` exits 2. That version check is
a local best-effort guard only: the route has no `If-Match` and the server never
compares the version, so it cannot prove atomicity and a request could still
race between the read and the PUT. Operation-specific approval here is the CLI's own `--confirm` plus the
reviewed diff, deliberately separate from the tuning/plan-apply approval path;
the server enforces tenant isolation, reference existence and the audit log, and
it has **no** assignment approval or consent entity.

A transport error or `5xx` from the PUT is reported as `outcome: "unknown"` with
`mutation_replayed: false`, `verification.saved_state: "unknown"` and a
`reconciliation: {"status": "not_read", …}` block; the CLI sends no retry and
does not re-read automatically — run `hc ams agents get` before doing anything
else. A `2xx` PUT answer whose body cannot be read as an agent object is also
`outcome: "unknown"`. A readback that is missing, cannot be read as an agent, is
a different agent or a different tenant, or does not show exactly the references
that were sent, is `outcome: "failed"` with `verification.saved_state: "failed"`
and a re-read pointer — never an internal error, never a replay.

Assignments are **not** reachable through the tuning path: a generated plan that
carries a reference field is refused by field name by
`hc ams sessions plans apply`, so a plan/tuning approval can never grant
capabilities.

Deleting an agent is one first-class, send-once destructive command. It requires
a saved `hc login` session (the command refuses before any request when none is
saved; env-var credentials are not accepted here) and the `--confirm`
acknowledgement of the target and disclosure block it prints:

```bash
uv run --frozen hc ams agents delete --environment dev --tenant-id TENANT_ID \
  --agent-id AGENT_ID --confirm
# the same acknowledgement, bound to the id being destroyed:
uv run --frozen hc ams agents delete --environment dev --tenant-id TENANT_ID \
  --agent-id AGENT_ID --confirm AGENT_ID
```

`--confirm` is required. It accepts the bare flag or the `AGENT_ID` being
deleted; a value that does not match `--agent-id` is refused (exit 2) before any
request, so a replayed acknowledgement cannot destroy a different agent. The
refusal names both spellings. This binding belongs to `agents delete` only — the
lifecycle and assignment `--confirm` flags stay plain acknowledgements.

Before the delete it reads the agent once and prints the resolved destructive
target (`agent_id`, `name`, `type`, `status`, `active`, `config_version`) and the
disclosure block to stderr **in every output mode**, so a scripted
`--output json` caller approves the record and the disclosure it was actually
shown rather than a phrase it never saw. `--output pretty` renders the same block
unchanged; `--output raw` is refused for every AMS command, so `json` is the
machine mode this command renders in. The same disclosure is carried under
`data.disclosure` in the envelope (`irreversible`, `generated_file`,
`dependency_guard`, `restore`, `authorization`, `recovery_trail`,
`review_staleness`), and it is the honest server contract:

- the delete is a **hard delete** of the agent row: no soft delete, no
tombstone, no retention window and no undelete route;
- the generated agent code file is removed from the runtime;
- the server guards **one** reference only — schedules that point at the agent
  answer `409` and nothing is deleted. Default-agent and orchestrator selection,
  engine bindings (`default_agent_id`), sessions and utility agents are **not**
  guarded (there is no foreign key), so this command cannot detect them;
- `hc ams restore` **cannot** restore a deleted agent (it replays tuning fields
  onto an existing agent). Export the configuration first with
  `hc ams agents export` if you may need it;
- the route needs an authenticated tenant user only: no admin/role check is
  applied;
- `--confirm` approves the **pre-read snapshot** printed above, not the row the
  server destroys: the route has no `If-Match` or version precondition, so a
  concurrent recreate or configuration change between that read and the
  `DELETE` is not detected and a different record can go. A `404`, or a `2xx`
  with no body, from the confirmation read is read as absence, and the error
  body cannot distinguish absence from a mid-operation authorization or scope
  change;
- only a server audit row (`AuditAction.DELETE`) remains.

The sequence is exactly one `GET /agents/{agent_id}`, exactly one
`DELETE /agents/{agent_id}` (204, no body) and exactly one confirmation `GET`.
A `404` from that confirmation read proves the row is gone, and so does a `2xx`
with no body (see below); a `404` is the only answer that also pins the status.
The envelope then reports `data.deleted: true`,
`data.evidence.deleted.status: "confirmed"` with `confirmation: "not_found"`
and `confirmation_status: 404`, plus
`data.evidence.side_effects` and `data.evidence.runtime` (both always
`unverified`) and `data.mutation_replayed: false`. The command prints no promise
of restore and has no `--force`, no `--dry-run`, no interactive prompt and no
`--expected-config-version` — the route has no `If-Match` or version check, so a
version flag would advertise protection the server does not offer.

Default `pretty` output prints the result as labelled lines next to the stderr
disclosure: `agent_id`, `deleted: true`, `target`, `disclosure`, `evidence`
(`deleted`/`side_effects`/`runtime`), `mutation_replayed: false`, and a closing
`verification:` block. `--output json` prints the full envelope on stdout,
unchanged and on stdout alone. Because the review also goes to stderr in `json`
mode, a **failing** confirmed delete prints that review followed by the one
error envelope on stderr: parse `stdout` for the success envelope, and take the
envelope from the first `{` on stderr when the command failed. That recipe
holds by construction, not by luck: every server-supplied value the review
prints (`agent_id`, `name`, `type`, `status`, `active`, `config_version`) has
its braces escaped as `\u007b`/`\u007d`, so the review never contains a literal
`{` or `}` and the first `{` on stderr always begins the envelope — an agent
named `beta{"deleted":true}` cannot break a scripted caller's parse.

Without `--confirm` the command is a **preview**: it reads the agent exactly once
(the same one bounded `GET`), prints `preview: --confirm was not passed, so
nothing has been sent.` plus the resolved destructive target and the whole
disclosure block to stderr, and exits 2 having sent no `DELETE`. With `--confirm`
the sequence above is unchanged: it re-reads before the single `DELETE`, so a
preview is never an approval token. The preview text *alone* is suppressed under
`--output json`/`raw` (it accompanies a usage refusal, which prints no envelope);
the confirmed path's review is not: it prints to stderr in every output mode, as
described above.

Refusals claim nothing and send nothing extra: an agent outside the explicit
tenant, the tenant identity mismatch and a delete target that no longer exists
are refused before the `DELETE` (the preview reports the same `not_found`); a `409` is a `conflict` refusal naming the referring
schedules; a `404` from the `DELETE` is `not_found`; and a `405` is the server's
refusal (`unsupported`). None of them is retried.

Every **other** non-2xx answer to the `DELETE` (`400`, `401`, `403`, `422`,
`429`, …) is a send-once refusal, never the generic runner failure: the envelope
is `outcome: "failed"` with `verification.saved_state: "failed"`, the category
comes from the status (`validation`, `authentication`, `authorization`,
`conflict` for the statuses the shared mapping covers; a status it does not map,
such as `429`, falls back to `transport` and is a known follow-up),
`data.evidence.deleted.confirmation` is `not_attempted` with
`confirmation_status: null` (no confirmation read was taken), and the message
says the delete was sent once, that nothing was replayed, and points at
`hc ams agents list`. It also carries the `reconciliation: {"status":
"not_read", …}` block: this command did not re-read the agent, so a scripted
caller can tell it apart from a read-shaped outcome.

A transport error, a `408` or a `5xx` from the `DELETE` is `outcome: "unknown"`
with `mutation_replayed: false`, `verification.saved_state: "unknown"` and a
`reconciliation: {"status": "not_read", …}` block; nothing is replayed and the
agent is never restored — run `hc ams agents list` before doing anything else.
The same unknown outcome applies when the delete was accepted but the
confirmation read could not be read, and when `Ctrl-C` interrupts either the
`DELETE` or the confirmation `GET`: the interrupt is caught (never swallowed
silently), the command still exits non-zero, and it prints the unknown-outcome
envelope instead of a plain failure that pretends nothing was sent.

A confirmation read that still returns the agent is `outcome: "failed"` with
`data.deleted: false`, `confirmation: "still_present"` and
`verification.saved_state: "failed"`, never success. A `2xx` body that cannot
be read as an agent is also `failed`, but `confirmation` is `unobserved`, with
wording that says the route still answers this path rather than claiming the
agent is still present. A confirmation read that answers 401/403 (or any other
non-404, non-2xx status) observed nothing about the row, so `confirmation` is
likewise `unobserved` — the same state as the guardrail delete — and never
`still_present`. A `2xx` with a **null or empty body** is the opposite case:
this server uses a `200` null body to say the agent does not exist (the same
answer `hc ams agents get` reports as `not_found`), so it proves absence exactly
as a `404` does. The envelope is then `outcome: "succeeded"` with
`data.deleted: true` and `confirmation: "not_found"` — never a `404` invented to
match the wording.

In every one of those non-`404` cases `confirmation_status` is `null`, and not
only when the body is unreadable: the SDK returns `None` for **any** response
with no content, before it looks at the status, so a `204`, a `202` with no body
and a `200` with a null body are indistinguishable at the client. A body-bearing
`2xx` does not pin the code either — `200`, `201` and `202` all read back the
same way. Only the `404` path can state a status it really observed, so only
that path records one.

Read-only department listing and inspection use the same explicit tenant-admin
targeting. They emit one versioned JSON result on stdout; failures emit one
categorized JSON result on stderr. Department descriptions, creator identities,
and evaluation-suite content are never included in the projection:

```bash
uv run --frozen hc ams departments list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams departments get --environment dev --tenant-id TENANT_ID \
  --department-id DEPARTMENT_ID
```

Department reads are complete and unpaged.

Department writes are first-class, send-once commands that require a saved
`hc login` session (run `hc login` first; env-var credentials are not accepted
for writes). Each write is sent exactly once — the CLI never retries a write —
and the result envelope carries the server's authoritative readback of the
department (same bounded projection as reads):

```bash
uv run --frozen hc ams departments create --environment dev --tenant-id TENANT_ID   --name "Clinical Operations"   [--description TEXT] [--parent-department-id PARENT_ID] [--key KEY]   [--required-eval-suite-ids SUITE_ID ...]
uv run --frozen hc ams departments update --environment dev --tenant-id TENANT_ID   --department-id DEPARTMENT_ID [--name NAME] [--description TEXT]   [--parent-department-id PARENT_ID] [--key KEY] [--required-eval-suite-ids SUITE_ID ...]
uv run --frozen hc ams departments activate --environment dev --tenant-id TENANT_ID   --department-id DEPARTMENT_ID
uv run --frozen hc ams departments deactivate --environment dev --tenant-id TENANT_ID   --department-id DEPARTMENT_ID
uv run --frozen hc ams departments delete --environment dev --tenant-id TENANT_ID   --department-id DEPARTMENT_ID [--confirm]
```

- `create` succeeds with `operation: "departments.create"` and
  `data.department` = the server's 201 readback. `--name` is required. There is
  no creator flag: before the write the CLI reads the saved session's own
  identity (`GET /admins/me`) and sends it as the server contract's
  `created_by_user_id`, so a create can never be attributed to another admin
  and the tenant is taken from that same authenticated admin. A failed identity
  read is a bounded failure and the write is never sent — it is not reported as
  an unknown outcome.
- `update` sends only the options you pass (an empty `--description ""`
  clears it); it refuses with exit 1 (`validation`) when no field option is
  given. `activate` and `deactivate` mark the department active/inactive.
  `hc ams departments activate` is the current spelling; the pre-#867 spelling
  `hc ams departments set-active` still works as a hidden alias (same single
  `POST /departments/{id}/activate`, same `departments.activate` envelope) but is
  no longer listed in `hc ams departments --help`, so existing scripts keep
  running while new documentation points at `activate`. `activate` is send-once:
  it refuses (exit 1, `authentication`, nothing sent) without a saved `hc login`
  session, and an uncertain outcome is never replayed.
- `delete --confirm` succeeds with `data: {"deleted": true}` (the server
  answers 204 with no body) and is irreversible: the CLI keeps no restore.
  Without `--confirm` the command is a **preview**: it reads the department
  exactly once (one bounded `GET`), prints `preview: --confirm was not passed,
  so nothing has been sent.` plus the resolved target and the disclosure block
  to stderr, and exits 2 having sent no `DELETE`. With `--confirm` the confirmed
  run makes the same single `GET` before its single `DELETE`, and the result
  envelope is the same, so a preview is never an approval token. The preview
  text is suppressed under `--output json`/`raw`, which keep stderr
  machine-parsable (the exit-2 refusal message itself is plain text on stderr;
  see #890).
- A 404 for the requested department is a `not_found` refusal naming the ID
  (on `create`, a 404 names the referenced department — typically the missing
  `--parent-department-id`; on `delete`, the reviewed read refuses identically
  and nothing is sent); a target/tenant or identity mismatch is a
  `validation` refusal; both exit 1
  without changes. The one exception is a tenant mismatch on the `create`
  readback: the department has already been created server-side, so the CLI
  reports a `validation` refusal that names the department, the tenant that
  received it, and tells you to run `hc ams departments get` — it never claims
  a landed write was a stopped no-op.
- If the transport times out, the connection drops mid-write, or the server
  answers an HTTP 5xx/408 before a readable result, the write's outcome is
  unknown: the CLI exits 1 with an `unknown` envelope
  (`verification.saved_state: unknown`, `verification.side_effects:
  unverified`, `data.mutation_replayed: false`, and
  `data.reconciliation.status: not_read`) whose message says the outcome is
  unknown and to check `hc ams departments get` before retrying. It never
  re-sends the write and never re-reads on its own.

Read-only team listing and inspection are also complete and unpaged. The bounded
projection omits descriptions and creator identities; it includes evaluation-suite
IDs but never suite content:

```bash
uv run --frozen hc ams teams list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams teams get --environment dev --tenant-id TENANT_ID --team-id TEAM_ID
```

Team writes are first-class, send-once commands that require a saved `hc login`
session (run `hc login` first; env-var credentials are not accepted for writes).
Each write is sent exactly once — the CLI never retries a write — and the result
envelope carries the server's authoritative readback of the team (same bounded
projection as reads). Teams have no active/inactive lifecycle, so there are no
`activate`/`deactivate` commands:

```bash
uv run --frozen hc ams teams create --environment dev --tenant-id TENANT_ID   --name "Escalation Team" --department-id DEPARTMENT_ID   --created-by-user-id ADMIN_USER_ID [--description TEXT] [--key KEY]   [--required-eval-suite-ids SUITE_ID ...]
uv run --frozen hc ams teams update --environment dev --tenant-id TENANT_ID   --team-id TEAM_ID [--name NAME] [--description TEXT] [--key KEY]   [--required-eval-suite-ids SUITE_ID ... | --clear-required-eval-suite-ids]
uv run --frozen hc ams teams delete --environment dev --tenant-id TENANT_ID   --team-id TEAM_ID
```

- `create` succeeds with `operation: "teams.create"` and `data.team` = the
  server's 201 readback. `--name`, `--department-id` and
  `--created-by-user-id` are required by the server contract (the tenant is
  taken from the saved session's authenticated admin, never from a flag).
- `update` sends only the options you pass (an empty `--description ""`
  clears it; `--clear-required-eval-suite-ids` sends an empty list to clear
  the required evaluation suites; `--key` accepts a nonblank key, it cannot be
  blanked); it refuses with exit 1 (`validation`) when no field option is
  given. `--required-eval-suite-ids` and `--clear-required-eval-suite-ids` are
  alternatives: passing both is a usage error (exit 2) and nothing is sent.
- `delete` succeeds with `data: {"deleted": true}` (the server answers 204
  with no body) and is irreversible. When AI agents still reference the team,
  the server refuses with 409 and the CLI reports one `conflict` refusal that
  names the team and says agents reference it; no changes were made.
- A 404 for the requested team is a `not_found` refusal naming the ID (on
  `create`, a 404 names the referenced department — the missing
  `--department-id`); a target/tenant mismatch on an update readback is a
  `validation` refusal that reports the landed write truthfully: it names the
  team and the tenant that actually received it and points to `hc ams teams
  get` — it never claims a landed write was stopped.
- If the transport times out, the connection drops mid-write, or the server
  answers an HTTP 5xx/408 before a readable result, the write's outcome is
  unknown: the CLI exits 1 with an `unknown` envelope
  (`verification.saved_state: unknown`, `verification.side_effects:
  unverified`, `data.mutation_replayed: false`, and
  `data.reconciliation.status: not_read`) whose message says the outcome is
  unknown and to check `hc ams teams get` before retrying. It never re-sends
  the write and never re-reads on its own.

The organization hierarchy is the backend-built department/team/agent tree. The
CLI preserves its order and nesting without issuing entity reads, joining records,
or inferring missing relationships:

```bash
uv run --frozen hc ams organization hierarchy --environment dev --tenant-id TENANT_ID
```

Read-only skill catalog listing and inspection are complete and unpaged. The
bounded projection includes only skill IDs, names, tool IDs, and audit timestamps;
it omits descriptions, tenant identity, creator identity, arbitrary response
fields, and tool details. Tool-ID order is preserved:

```bash
uv run --frozen hc ams skills list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams skills get --environment dev --tenant-id TENANT_ID --skill-id SKILL_ID
```

The CLI does not assign skills. Skill commands do not
read guardrails. They make exactly one catalog GET after tenant-admin login
and does not paginate, retry, cache, join, or infer additional catalog data.
Skill reads refuse target files, root credentials or base URLs, raw output,
blank or dot-segment skill IDs, and installed SDKs that do not provide both
skill list and get methods. These local refusals happen before tenant-admin login
or catalog reads.

Skill writes are first-class, send-once commands that require a saved `hc login`
session (env-var credentials are not accepted for writes). Each write is sent
exactly once — the CLI never retries a write — and the result envelope carries
the server's authoritative readback of the skill (same bounded projection as
reads). Skills have no active/inactive lifecycle, so there are no
`activate`/`deactivate` commands:

```bash
uv run --frozen hc ams skills create --environment dev --tenant-id TENANT_ID --name "Schedule Lookup" --created-by-user-id ADMIN_USER_ID [--description TEXT] [--tool-ids TOOL_ID ...]
uv run --frozen hc ams skills update --environment dev --tenant-id TENANT_ID --skill-id SKILL_ID [--name NAME] [--description TEXT] [--tool-ids TOOL_ID ... | --clear-tool-ids]
uv run --frozen hc ams skills delete --environment dev --tenant-id TENANT_ID --skill-id SKILL_ID
```

- `create` succeeds with `operation: "skills.create"` and `data.skill` = the
  server's 201 readback. `--name` and `--created-by-user-id` are required by
  the server contract; the tenant is taken from the saved session's
  authenticated admin, never from a flag. The server does not validate that
  `--tool-ids` reference real tools (blank tool IDs are rejected client-side).
- `update` sends only the options you pass (an empty `--description ""`
  clears it; `--clear-tool-ids` sends an explicit empty list to clear the
  skill's tool bindings); it refuses with exit 1 (`validation`) when no field
  option is given. `--tool-ids` and `--clear-tool-ids` are alternatives:
  passing both is a usage error (exit 2) and nothing is sent.
- `delete` succeeds with `data: {"deleted": true}` (the server answers 204
  with no body) and is irreversible. When AI agents still reference the skill
  (via their `skill_ids`), the server refuses with 409 and the CLI reports one
  `conflict` refusal that names the skill and says agents reference it; no
  changes were made.
- A 404 for the requested skill is a `not_found` refusal naming the ID (on
  `create`, a 404 is a generic referenced-record refusal — the server does not
  validate tool references and the CLI does not either); a target/tenant
  mismatch on a write readback is a `validation` refusal that reports the
  landed write truthfully: it names the skill and the tenant that actually
  received it and points to `hc ams skills get` — it never claims a landed
  write was stopped.
- If the transport times out, the connection drops mid-write, or the server
  answers an HTTP 5xx/408 before a readable result, the write's outcome is
  unknown: the CLI exits 1 with an `unknown` envelope
  (`verification.saved_state: unknown`, `verification.side_effects:
  unverified`, `data.mutation_replayed: false`, and
  `data.reconciliation.status: not_read`) whose message says the outcome is
  unknown and to check `hc ams skills get` before retrying. It never re-sends
  the write and never re-reads on its own.

Read-only policy catalog listing and inspection are complete and unpaged. The
bounded projection includes only policy IDs, names, department IDs, guardrail
IDs, active status, and audit timestamps. It never emits protected policy
content, descriptions, tenant or creator identity, arbitrary response fields,
or joined department/guardrail details. Guardrail-ID order is preserved:

```bash
uv run --frozen hc ams policies list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams policies get --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
```

The CLI does not export protected policy content and never emits it: policy
`--content` is write-only — it is sent to the server but never appears in any
command output, error message, or readback. Read commands make exactly one
catalog GET after tenant-admin login and do not paginate, retry, cache, join,
or infer referenced resources. Policy reads refuse target files, root
credentials or base URLs, raw output, blank or dot-segment policy IDs, and
installed SDKs without both policy methods before tenant-admin login or
catalog reads. Successful reads make no assignment, deployment, serving,
enforcement, or effectiveness claim.

Policy writes are first-class, send-once commands that require a saved `hc login`
session (env-var credentials are not accepted for writes). Each write is sent
exactly once — the CLI never retries a write — and the result envelope carries
the server's authoritative readback of the policy (same bounded projection as
reads: never content, description, tenant identity, or creator identity).
Policies have an `active` lifecycle flag, so `activate` and `deactivate`
commands exist (they PUT the `active` field):

```bash
uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID [--description TEXT] [--content TEXT | --content-file PATH] [--guardrail-ids GUARDRAIL_ID ...]
uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID [--name NAME] [--description TEXT] [--content TEXT | --content-file PATH] [--guardrail-ids GUARDRAIL_ID ... | --clear-guardrail-ids]
uv run --frozen hc ams policies activate --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
uv run --frozen hc ams policies deactivate --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
uv run --frozen hc ams policies delete --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID
```

- `create` succeeds with `operation: "policies.create"` and `data.policy` =
  the server's 201 readback. `--name`, `--department-id` and
  `--created-by-user-id` are required by the server contract; the tenant is
  taken from the saved session's authenticated admin, never from a flag. The
  server does not validate that `--guardrail-ids` reference real guardrails
  (blank guardrail IDs are rejected client-side). `--content` is write-only:
  it is sent but never echoed back.
- The protected policy content can also arrive from an owner-only file, which
  keeps it out of the command line entirely (argv, the process table under
  `ps`/`/proc/*/cmdline`, shell history and CI logs all see only the path):

  ```bash
  uv run --frozen hc ams policies create --environment dev --tenant-id TENANT_ID --name "Appointment Safety" --department-id DEPARTMENT_ID --created-by-user-id ADMIN_USER_ID --content-file PATH
  uv run --frozen hc ams policies update --environment dev --tenant-id TENANT_ID --policy-id POLICY_ID --content-file PATH
  ```

  The file is read locally, before the login check and any request, by the same
  reader the sessions commands use: it must be a readable owner-only regular
  text file (no symlinks, no directories, not group- or world-readable) and
  must hold 1-20000 characters. Its content is never printed, and an invalid,
  empty or oversized file is refused locally with a message that names the
  discipline, never the text. The output is identical to the inline form:
  `policies.create` / `policies.update` with `data.policy`.
- Exactly one content source is accepted per write: passing `--content` with
  `--content-file` is a usage error (exit 2) that sends nothing. The inline
  `--content` flag remains supported for convenience and remains exposed in
  argv, the process table and shell history — use the file channel for
  anything sensitive. An empty `--content ""` on `update` is still the
  documented clear; an empty `--content-file` is a `validation` refusal, so a
  truncated or misdirected file can never clear protected content by accident.
- `update` sends only the options you pass (an empty `--description ""` or
  `--content ""` clears it; `--clear-guardrail-ids` sends an explicit empty
  list to clear the policy's guardrail bindings); it refuses with exit 1
  (`validation`) when no field option is given. `--guardrail-ids` and
  `--clear-guardrail-ids` are alternatives: passing both is a usage error
  (exit 2) and nothing is sent.
- `activate`/`deactivate` PUT `active: true|false` and succeed only when the
  readback confirms the requested flag.
- `delete` succeeds with `data: {"deleted": true}` (the server answers 204
  with no body) and is irreversible. If the server refuses with 409, the CLI
  reports one `conflict` refusal naming the policy; no changes were made.
- Any other 409 on `create`, `update`, `activate` or `deactivate` is one
  write-worded `conflict` refusal (naming the policy where one was given) that
  says the server refused the write and to review the policy's current server
  state before retrying; no changes were made. Writes are never refused with
  the read-era conflict wording.
- A 404 for the requested policy is a `not_found` refusal naming the ID (on
  `create`, a 404 names the referenced `--department-id`); a target/tenant
  mismatch on a write readback is a `validation` refusal that reports the
  landed write truthfully: it names the policy and the tenant that actually
  received it and points to `hc ams policies get` — it never claims a landed
  write was stopped.
- If the transport times out, the connection drops mid-write, or the server
  answers an HTTP 5xx/408 before a readable result, the write's outcome is
  unknown: the CLI exits 1 with an `unknown` envelope
  (`verification.saved_state: unknown`, `verification.side_effects:
  unverified`, `data.mutation_replayed: false`, and
  `data.reconciliation.status: not_read`) whose message says the outcome is
  unknown and to check `hc ams policies get` before retrying. It never
  re-sends the write and never re-reads on its own.

Governed authoring sessions and their messages are first-class commands over the
VibeCoder session routes. Creating a session and appending a message make no model
call; they only record authoring state. Sessions are tenant- and user-scoped by the
server, newest first, and expired sessions are not returned. `--limit` is the only
pagination the server offers (sessions default 50, maximum 100; messages default
100, maximum 200) — there are no offsets or cursors, and a limit outside that range
is refused locally:

```bash
uv run --frozen hc ams sessions list --environment dev --tenant-id TENANT_ID [--limit N]
uv run --frozen hc ams sessions get --environment dev --tenant-id TENANT_ID --session-id SESSION_ID
uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID [--agent-id AGENT_ID] [--mode plan|implement] [--initial-prompt TEXT | --initial-prompt-file PATH]
uv run --frozen hc ams sessions messages list --environment dev --tenant-id TENANT_ID --session-id SESSION_ID [--limit N]
uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID [--content TEXT | --content-file PATH] [--mode plan|implement] [--idempotency-key KEY]
```

- `list` emits `operation: "sessions.list"`, `data.sessions` (bounded projections
  of session ID, agent ID, operation, mode, status and audit timestamps) and a
  `page` block. These routes page with a limit only, so the block always says
  which limit was in force: with `--limit N` it is
  `{"kind": "limit", "limit": N, "returned_count": N, "continuation": null}`,
  and without it the server's own default page was applied, which the block
  reports as `{"kind": "server_default", "server_default_limit": 50,
  "returned_count": N, "continuation": null}` (50 sessions, 100 messages) — so
  a truncated page is never mistaken for a complete list. `get` emits
  `operation: "sessions.get"` and `data.session`. The projections never include the session's protected
  `initial_prompt`, tenant or user identity, or arbitrary response fields.
- Default `pretty` output prints `list` as a table with honest headers —
  `ID`, `AGENT ID`, `OPERATION`, `STATUS`, `EXPIRES AT` for sessions and `ID`,
  `ROLE`, `MODE`, `PLAN ID`, `CREATED AT` for messages — followed by an entry
  count (`1 entry` / `N entries`), and `(no entries)` for an empty list. These
  projections carry no `name`, so no `NAME` column is invented and the `id` is
  always shown. `get`, `create` and `messages add` print `key: value` lines.
  `--output json` prints the full envelope exactly as before; pretty output is
  additive and never changes the envelope.
- `create` and `messages add` require a saved `hc login` session (env-var
  credentials are not accepted for writes). Each write is sent exactly once and
  the envelope carries the server's authoritative readback:
  `operation: "sessions.create"` with `data.session`, and
  `operation: "sessions.messages.add"` with `data.message` (message ID, role,
  mode, plan ID, model, timestamps) plus `data.idempotency_key` — the key the
  server read back, when the returned message carries one. When the readback
  does not echo a key, the field is omitted: the envelope never asserts a
  key→message association the server did not confirm. An `--idempotency-key`
  longer than 128 characters (or an empty one) is refused locally with a
  `validation` error naming the flag, before anything is sent. Protected free text — `--initial-prompt` and `--content` — is sent to
  the server but never appears in the envelope, a refusal, a diagnostic or a log.
- The protected free text can also arrive from an owner-only file, which keeps it
  out of the command line entirely (argv, the process table under
  `ps`/`/proc/*/cmdline`, shell history and CI logs all see only the path):

  ```bash
  uv run --frozen hc ams sessions create --environment dev --tenant-id TENANT_ID --initial-prompt-file PATH
  uv run --frozen hc ams sessions messages add --environment dev --tenant-id TENANT_ID --session-id SESSION_ID --content-file PATH
  ```

  The file is read locally, before the login check and any request, by the same
  reader `hc ams sessions plans generate --prompt-file` uses: it must be a
  readable owner-only regular text file (no symlinks, no directories, not
  group- or world-readable), and must hold 1-20000 characters — the same bound
  the inline flag is now held to, so neither channel can carry an empty or
  over-long value past the local check. Its content is never printed, and an
  invalid, empty or oversized file is refused locally with a message that
  names the discipline, never the text. The output is identical to the inline form: `sessions.create`
  with `data.session`, `sessions.messages.add` with `data.message`.
- Exactly one source is accepted per field. Passing `--initial-prompt` with
  `--initial-prompt-file`, or `--content` with `--content-file`, is a usage
  error (exit 2) that sends nothing; `messages add` with neither is the same
  usage error. The inline `--initial-prompt` and `--content` flags remain
  supported for convenience, and remain exposed in argv, the process table and
  shell history — use the file channel for anything sensitive.
- `--initial-prompt` becomes the session's first `user` message in the same
  server transaction; `hc ams sessions messages list` shows it afterwards.
- `--mode implement` is not available from the server yet: `create` reports one
  `unsupported` refusal (the server answers 501) and creates nothing, and a
  `messages add` the server answers 501 for is the same `unsupported` refusal
  with no message added. Both refusals report the 501 the server actually sent
  and name implement mode as the documented reason, rather than asserting the
  requested mode was the cause. A message
  whose `--mode` differs from its session's mode is refused by the server with
  409 and reported as one write-worded `conflict` refusal that names the
  session, mentions a mode mismatch as one possible cause, and points at
  `hc ams sessions get`; no message is added, and the read-era conflict wording
  is never used.
- A missing, expired or other user's session is a `not_found` refusal naming the
  session on `get`, `messages list` and `messages add`; on `create`, a 404 names
  the requested `--agent-id`.
- Messages with an `--idempotency-key` are replay-safe: the server answers 201
  with the existing message instead of inserting a second one, and the command
  reports that message (its original ID) — a replay is never reported as a new
  write, and the command itself never re-sends a write. The envelope echoes the
  server's read-back key, never a key the server did not confirm.
- If the transport times out or the connection drops mid-write, the write's
  outcome is unknown: the CLI reports one `transport` error whose message says so
  and points at `hc ams sessions list`/`hc ams sessions messages list` before
  retrying. A write readback that lands in a different tenant is reported
  truthfully as `validation`, naming the tenant that actually received it — a
  landed write is never reported as stopped.
- Reads refuse target files, root credentials or base URLs, raw output, blank or
  dot-segment session IDs, out-of-range limits, and installed SDKs without the
  session list/get and message list methods before tenant-admin login or any
  read. `messages list` needs only `messages.list` — the write method `add` is
  not required for the read. The writes check their own methods in the same
  preflight — each command requires exactly the write method it calls, so
  `create` refuses an installed SDK without `sessions.create` and `messages add`
  one without `messages.add` before tenant-admin login and before any read or
  write, and neither is refused for the write the other command performs. A single-object route whose 2xx body is not a JSON
  object is refused as a `validation` error, never reported as an internal
  failure. The existing `hc ams plan|show|apply|restore` tuning commands and their
  signatures are unchanged.
- These session and message commands do not attach files, apply or roll back
  configuration, or send session history to a model. Plan review is its own
  command group (below).

Session attachments are three commands over the live VibeCoder attachment routes.
There is no download command: the server exposes no attachment content route, so
the CLI never fetches, prints or reconstructs a file's bytes:

```bash
uv run --frozen hc ams sessions attachments list --environment dev --tenant-id TENANT_ID --session-id SESSION_ID
uv run --frozen hc ams sessions attachments upload --environment dev --tenant-id TENANT_ID --session-id SESSION_ID --file PATH
uv run --frozen hc ams sessions attachments delete --environment dev --tenant-id TENANT_ID --session-id SESSION_ID --attachment-id ATTACHMENT_ID
```

- `list` emits `operation: "sessions.attachments.list"` and `data.attachments`,
  each a bounded projection of `id`, `filename`, `content_type`, `size_bytes`,
  `index_status` and `created_at`. The server offers no pagination on this
  route, so there is no `page` block and no `--limit`. The projections never
  include `storage_key`, `content_hash`, tenant or user identity, or any file
  content. An agent-backed session lists the agent attachments linked to the
  session; a session with no agent lists its draft attachments. The server
  guarantees no ordering, so the CLI claims none.
- `upload` reads `--file` locally, reads the session once first, then sends the
  file exactly once as the multipart `file` part under its bare filename, and
  reports the server's authoritative readback as
  `operation: "sessions.attachments.upload"` with `data.attachment` (the same
  projection as `list`). It requires a saved `hc login` session. The file's
  bytes are never written to stdout, stderr, an error envelope, a diagnostic or
  a log; only its name and size appear. An agent-backed session derives the
  content type from the extension and stores an agent attachment (with an
  indexing status); a session with no agent stores a draft attachment and
  accepts any extension.
- Before sending, `upload` refuses a missing or unreadable file and a file
  larger than 5 MiB without any request: those bounds apply to both paths. The
  extension allow-list (`txt`, `md`, `pdf`, `docx`) is the agent path's only,
  because that path derives the content type from the extension, so it is
  applied after the session read and only when the session has an `agent_id`; a
  draft upload of any other extension is sent, and the server answers. The
  session read is a separate route, so the upload POST itself is still sent
  exactly once. These local pre-checks mirror the server's own bounds; they
  never replace the server's answer. When the server
  does refuse, its status is reported as sent and never normalised: `413` (the
  route's stream cap) and `422` (the attachment service's size check, an unsafe
  or blank filename, or an unsupported extension on the agent path) both become
  one `validation` refusal naming the status and the server's own detail, and
  nothing is attached. A missing, expired or other user's session is a
  `not_found` refusal naming the session.
- `delete` removes an attachment with a `204` (no body, so the envelope is built
  from the request identity, never a fabricated response). It requires a saved
  `hc login` session and reads the session once first, because the route has two
  different effects and the wording must be truthful: for an agent-backed
  session the file is only **detached** from the session and the agent
  attachment and its stored object survive (`data.effect: "detached"`), while a
  session with no agent has its draft attachment **deleted**
  (`data.effect: "deleted"`). `data` also carries `attachment_id`, and `detail`
  states the effect in words. The DELETE itself is sent exactly once. A missing
  session, or an attachment ID that is not attached to the session, is a
  `not_found` refusal; nothing is removed.
- If the transport times out or the connection drops mid-write, the upload's or
  delete's outcome is unknown: the CLI reports one `transport` error saying so
  and pointing at `hc ams sessions attachments list` for that session before
  retrying. It never re-sends automatically; `list` is the reconciliation step.
  A refused or failed upload creates no session link, sends no message,
  generates no plan and makes no model call, and a landed write readback in
  another tenant is reported truthfully as `validation`.
- Attachment commands refuse target files, root credentials or base URLs, raw
  output, blank or dot-segment session and attachment IDs, and installed SDKs
  without the attachment list/upload/delete methods before tenant-admin login
  or any request. Uploads are multipart requests; `APIClient.request` forwards
  `files`/`data` for them and every existing JSON caller's wire bytes are
  unchanged.

Exact create/configure plans are five commands over the existing VibeCoder plan
routes: two review commands, one send-once apply, one send-once rollback and one
read-only reconciliation. There is no approval receipt and no direct agent write:

```bash
uv run --frozen hc ams sessions plans generate --environment dev --tenant-id TENANT_ID --session-id SESSION_ID [--operation modify|create] [--agent-type Application|Utility] [--prompt-file PATH] [--idempotency-key KEY] --out PATH
uv run --frozen hc ams sessions plans get --environment dev --tenant-id TENANT_ID --plan-id PLAN_ID --out PATH
uv run --frozen hc ams sessions plans apply --environment dev --tenant-id TENANT_ID --plan-id PLAN_ID --confirm-hash 64_HEX_PLAN_HASH [--expected-config-version N] --confirm
uv run --frozen hc ams sessions plans rollback --environment dev --tenant-id TENANT_ID --plan-id PLAN_ID --expected-config-version N [--reason TEXT] --confirm
uv run --frozen hc ams sessions plans reconcile --environment dev --tenant-id TENANT_ID --plan-id PLAN_ID
```

- `generate` sends exactly one `POST /vibecoder/sessions/SESSION_ID/plan` with
  the explicit session ID; it makes no preflight read and no other request, no
  message write, no `PUT` and no apply. The server runs the planner once. It
  requires a saved `hc login` session and reports
  `operation: "sessions.plans.generate"` with `data.plan` and `data.out`.
  An `--idempotency-key` longer than 128 characters (or an empty one) is
  refused locally with a `validation` error naming the flag, before anything
  is sent.
  `get` sends exactly one `GET /vibecoder/plans/PLAN_ID` and reports
  `operation: "sessions.plans.get"`; a reload is immutable, so it returns the
  same `plan_hash` and diff however the session changed afterwards. `get` is a
  read and needs no saved `hc login` session: it authenticates like every other
  session read, with the saved session when there is one and otherwise with the
  environment's own `HC_AMS_ADMIN_EMAIL`/`HC_AMS_ADMIN_PASSWORD` credentials.
  A refusal is worded for the operation that was refused, so a `422` or `503`
  on `get` never claims a plan "was generated": `generate` says no plan was
  generated, `get` says no plan was reloaded.
- `--out` is required and must be a new path: the review is written to a
  newly created owner-only file (mode `0600`, no symlinks, never overwritten).
  A refusal removes the reservation again, so a refused review never leaves a
  file behind.
- The review file carries the exact target (`environment`, `tenant_id`,
  `session_id`, `agent_id` — explicit `null` for a creation plan — and
  `plan_id`), `operation`, `status`, `base_config_version` (`null` for create),
  `plan_hash`, `model`, `summary`, the advisory `review_warnings` with their
  own `severity`, `file_actions` as attachment **IDs only**, and the diff:
  `changes` with each returned field's `before` and `after` for a configure
  plan, or `definition` (name, description, type, model, `is_orchestrator`,
  department/team, instructions, greetings, opening suggestions) plus
  `references` (catalog IDs grouped by department/team/tool/skill/policy/
  guardrail/data-source) for a creation plan. `plan_json["original_prompt"]`
  is never projected, and `warnings`/`skipped_references` are reported as
  counts, not bodies.
- The review file marks the text a model produced: `content_origin` is
  `"model-generated"` and `model_generated_fields` lists the fields whose values
  came from the planner — `summary`, `changes.*.why`,
  `definition.instructions`/`greetings`/`name`/`description`/opening suggestions
  and `review_warnings[].message`. Those values are data to inspect before you
  approve, never instructions to the reader or to an agent that later reads the
  file.
- stdout stays bounded and free-text-free: plan ID, `plan_hash`, operation,
  status, base config version, agent ID, model, sorted changed-field **names**
  (create: reference counts by kind) and finding counts. No prompt,
  instructions, greeting, summary, message content or plan value is printed to
  stdout, stderr, a diagnostic or an error string.
- Default `pretty` output renders `generate`, `get`, `apply`, `rollback` and
  `reconcile` as labelled lines — `plan:` with the bounded metadata, then
  `evidence:` (`saved`/`side_effects`/`runtime`) and `mutation_replayed: false`
  where the command reports them, and a closing `verification:` block for the
  writes. A write is never printed as a raw JSON blob in the default mode.
  `--output json` prints the full envelope, unchanged.
- The prompt is protected free text and never comes from argv: `--prompt-file`
  reads one owner-only regular text file (no symlinks, at most 20000
  characters) and sends it once. A file that is too large, missing, unreadable,
  not UTF-8 text, or not an owner-only regular file is a `validation` refusal
  naming the reason and never quoting the text. Ownership is checked differently
  per platform: on POSIX it is the file's mode and owner; on Windows the opened
  handle's owner and DACL must grant access to your account only, so a file that
  inherited a grant to another principal (SYSTEM, Administrators) is refused as
  not owner-only. Keep the prompt file in a directory only your account can read.
  `--prompt-file` is optional — the server falls
  back to the session's initial prompt or last message — and `--agent-type`
  applies to `--operation create` only (with `modify` it is a usage error
  before login or any request). `--prompt-file` is not mirrored as a local
  "prompt required" rule: a creation plan with no prompt anywhere keeps the
  server's own `422 "a prompt is required for agent creation"`.
- It refuses rather than endorses. A configure plan that changes anything
  outside the existing tuning field set (`instructions`, `greetings`) is
  refused by field name — in particular capability/permission expansion
  (`tool_ids`, `skill_ids`, `policy_ids`, `guardrail_ids`, `data_source_ids`,
  `department_id`, `team_id`, `is_orchestrator`), lifecycle/`status`, model and
  file actions: permission assignment stays a separately reviewed capability.
  It also refuses a plan whose `status` is not `proposed`, whose `plan_hash` is
  not 64 hex characters, whose `session_id`/`tenant_id`/`operation` differ from
  the explicit target, whose `plan_json` carries unknown keys, or whose file
  actions name anything but an attachment ID. A `status: "invalid"` plan is
  refused instead of reviewed, and its `validation_errors` are reported by
  count and never quoted — that server text can contain protected field
  values, so read it in the AMS console. Non-`advisory` review findings are
  refused the same way; advisory findings are displayed in the review file.
  A creation plan that claims an `agent_id` is refused, and no existing agent
  ID is ever fabricated or required. So is a creation plan whose proposed agent
  carries a field outside the documented definition and reference set
  (`name`, `description`, `type`, `model`, `preferred_model`,
  `is_orchestrator`, `department_id`, `team_id`, `instructions`, `greetings`,
  `opening_suggestions`, `opening_suggestions_options`, and the catalog
  `*_ids`), or whose reference field holds anything but non-blank catalog IDs:
  the operator never approves a definition the review did not show.
- Server answers are preserved, not replaced: a missing, expired or other
  user's session or plan is a `not_found` refusal naming it, and an unavailable
  planner (`503`) is a `transport` refusal with no file written. A `422` from
  the plan route is a `validation` refusal, but its detail is only quoted when
  it is a known static contract message that carries no field values — the
  documented `"a prompt is required for agent creation"`. Any other `422`
  detail can quote protected field values, exactly like `validation_errors`
  and blocking review findings, so it is reported by category only and you read
  it in the AMS console. A dropped connection on `generate` reports one `transport`
  error saying the plan's outcome is unknown and points at
  `hc ams sessions messages list`/`plans get` before retrying; the request is
  never re-sent automatically.
- Both commands refuse target files, root credentials or base URLs, raw output,
  blank or dot-segment session and plan IDs, a prompt file that is not a
  readable owner-only regular file, and installed SDKs without the plan
  generate/get methods before tenant-admin login (or, for `generate`, before
  any request). The existing `hc ams plan|show|apply|restore` tuning commands
  and their signatures are unchanged.

Applying, rolling back and reconciling one plan are separate capabilities on the
same routes. The commands never retry, never replay, never send a second
mutation and never fall back to `PUT /agents/{id}`:

- `apply` requires a saved `hc login` session, `--confirm` and the exact
  64-hex `--confirm-hash` from the review. It reads the plan **once**, runs its
  local pre-checks, sends **exactly one**
  `POST /vibecoder/plans/PLAN_ID/apply` with the reviewed `plan_hash`, the
  version the plan itself carries and `confirm: true`, then reads the plan (and
  the agent, when the plan names one) back. Approving a plan binds exactly what
  was reviewed: a configure plan whose changes fall outside `instructions`/
  `greetings`, or which carries file actions, is refused by field name even
  though the server would accept it, and a creation plan that claims an agent
  ID, reports existing-field changes or carries a file write/path action is
  refused the same way. A plan that is not `proposed`, carries
  `validation_errors`, has blocking review findings, or whose hash no longer
  matches `--confirm-hash` is refused before anything is sent.
- A **configure** `apply` requires `--expected-config-version` equal to the
  plan's own `base_config_version`; a mismatch is a local refusal, never a stale
  send. A **creation** plan has no baseline (`base_config_version` is `null`),
  so `--expected-config-version` must **not** be supplied: the request sends `1`
  only because the pinned route schema requires `expected_config_version >= 1`
  while the server's creation branch never reads it. That `1` is a documented
  **protocol filler, not a baseline claim**, and the CLI never reports it as
  evidence.
- `apply` reports three evidence sections independently and never collapses
  them into "applied": `data.evidence.saved` (the plan reloaded after the write
  is `applied`/`publish_failed`; for a configure plan the plan's **own** agent
  was read back in this tenant, its `config_version` advanced by exactly one
  from the plan's own `base_config_version`, and every reviewed field equals its
  `after` value; for a creation plan the created `agent_id` was read back in
  this tenant and no version baseline is claimed), `data.evidence.side_effects`
  (files are `unverified` because the pinned contract exposes no attachment/file
  readback, and publication is `not_required` when the server reports
  `publication_status: not_applicable`, otherwise `unknown`/`failed` — never
  "serving"), and `data.evidence.runtime` (always `unverified`, because there is
  no serving/runtime route). `data.mutation_replayed` is always `false`.
- Exit codes are strict: `0` **only** when the write is provably saved and
  readback-confirmed (the `verification` block then reads `saved_state:
  confirmed`, `side_effects: unverified`, `runtime: unverified` — or
  `side_effects: failed` when the evidence above reports a failed publication,
  so one envelope never carries two different side-effect claims). A readback
  mismatch, a saved-but-unpublished result, or an unknown outcome exits nonzero
  with the bounded JSON envelope on stderr.
- `apply` surfaces the server's own answers and never its free text: a missing
  plan is a `not_found` refusal naming it; a `409` names only the structured
  reason code (`plan_hash_mismatch`, `already_applied`,
  `plan_not_applicable`, `attachments_changed`, `stale_baseline`,
  `concurrent_edit`) with bounded local wording; a `422` is reported by
  validation-error **count** only, because that server text can quote protected
  field values; a `400` is worded as an invalid request, never as a missing
  confirmation, because every apply and rollback this CLI sends already carries
  `confirm: true`. `--confirm-hash` accepts either hex case and is compared
  case-insensitively against the reviewed `plan_hash`.
- **Unknown outcome is never success and never a replay.** A read-back that
  fails *after* the response bound this plan is also an unknown outcome, not a
  failure: the receipt proved the server accepted the write, so a mismatched
  readback request (a `5xx`, a dropped connection, or an unreadable answer) is
  reported as `outcome: "unknown"` with the same reconciliation. When that
  reconciliation itself cannot be read, `data.reconciliation` is
  `{"status": "not_read", "detail": …}` with a bounded reason — a failed
  reconciliation never looks like an empty one, and never a bare `null`.
  On a transport
  failure or any `5xx` from `/apply` (the mutation may have committed) the
  command sends nothing further, records `outcome: "unknown"`, runs the same
  read-only reconciliation `reconcile` performs, prints it together with
  `mutation_replayed: false`, and exits nonzero telling the operator to inspect
  before acting. The only supported follow-up is `plans reconcile`, or
  `plans rollback` once the state is known.
- `rollback` requires a saved `hc login` session, `--confirm`,
  `--expected-config-version` equal to the agent's **current**
  `config_version`, and reads the plan and agent first. It requires the plan to
  be an applied configure plan (`status` `applied` or `publish_failed`, with an
  `agent_id`); a mismatch is refused locally, never sent. It then sends
  **exactly one** `POST /vibecoder/plans/PLAN_ID/rollback` (the pinned request
  schema requires `reason`, so `--reason` is sent as a bounded string, `""`
  when omitted, and is never echoed), reads both back, and reports the same
  three evidence sections. `--reason` is protected free text: it never reaches
  stdout, stderr, a diagnostic or an error string. A rollback whose plan records
  no field changes is refused with wording for a configure plan that proposed
  nothing — only a plan whose `operation` is `create` is worded as a creation
  plan — and a plan that changes a field outside `instructions`/`greetings` is
  refused by field name, exactly as `apply` does. A rollback `409`/`422` is
  reported like `apply`'s: the structured reason code, or the validation-error
  count, and never the server's free text.
- A saved-but-unpublished `apply` (`status: publish_failed`) is refused with
  category `publication`, not `transport`: the change was saved and read back, so
  triage must not be pointed at a network failure.
- **Rollback is not restore and neither is deletion recovery.** `hc ams
  restore` is the tuning contract's selected-field restore: receipt-bound fields
  put back on the same agent. `hc ams sessions plans rollback` asks the server to
  revert the fields **one applied plan** changed, version-checked. A creation
  plan changed no existing field, so it is refused locally: deleting an agent a
  creation plan created is a different capability this CLI does not add. The
  existing `hc ams plan|show|apply|restore` tuning commands and their
  signatures, output and receipts are unchanged.
- `reconcile` is the read-only recovery path: it sends no `POST`, no `PUT` and no
  `DELETE`. It reloads the plan, reads its agent when it names one, and reports
  the plan `status` and `plan_hash`, the `agent_id`, the agent's
  `config_version`, which planned field values are `present`/`absent`/`other`,
  and `mutation_replayed: false`; side effects and runtime stay `unverified`.
  It is what an operator runs after any uncertain outcome, before any further
  write. A field the review did not endorse (`PLAN_REVIEW_FIELDS` is
  `instructions`/`greetings`) is reported as `unvalidated` and its value is
  never read from the agent; a change whose `before` and `after` are the same
  value reports `unchanged`, so a committed no-op rollback is not mistaken for a
  failed readback.
- All three refuse target files, root credentials or base URLs, raw output,
  plan IDs that are not 1-128 characters of letters, digits, `.`, `_`, `:` or
  `-` (a value carrying control characters or terminal escapes is refused and
  never echoed), an apply/rollback without `--confirm`, a malformed
  `--confirm-hash`, an out-of-range `--expected-config-version`, a `--reason`
  longer than 2000 characters, and installed SDKs without the plan
  get/apply/rollback **and** `ams_admin.get_agent` methods before tenant-admin
  login or any request. The agent read is required up front because apply,
  rollback and reconcile all read the agent back: checking it only there would
  let a `POST` that may have committed die as a plain `unsupported` failure.

Guardrail catalog listing and inspection are complete and unpaged. The bounded
projection includes only guardrail IDs, names, and audit timestamps. It never
emits protected rules, descriptions, tenant or creator identity, arbitrary
response fields, or joined policy/agent details:

```bash
uv run --frozen hc ams guardrails list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams guardrails get --environment dev --tenant-id TENANT_ID \
  --guardrail-id GUARDRAIL_ID
```

Reads make exactly one catalog GET after tenant-admin login and do not paginate,
retry, cache, join, or infer additional data. Guardrail reads refuse target
files, root credentials or base URLs, raw output, blank or dot-segment guardrail
IDs, and installed SDKs without both catalog methods before tenant-admin login or
catalog reads.

Guardrail writes are first-class, send-once commands that require a saved
`hc login` session (env-var credentials are not accepted for writes). Each write
is sent exactly once — the CLI never retries a write — and the result envelope
carries the server's authoritative readback of the guardrail (same bounded
projection as reads: never rules, description, tenant identity, or creator
identity). A guardrail has no `active` flag and no `config_version`, so there is
no `activate`/`deactivate` command and no expected-version option: the server's
write routes carry no `If-Match`, and the CLI will not advertise a guard the
server does not offer.

```bash
uv run --frozen hc ams guardrails create --environment dev --tenant-id TENANT_ID --name NAME --created-by-user-id ADMIN_USER_ID [--description TEXT] [--rules-file RULES_JSON_FILE]
uv run --frozen hc ams guardrails update --environment dev --tenant-id TENANT_ID --guardrail-id GUARDRAIL_ID [--name NAME] [--description TEXT] [--rules-file RULES_JSON_FILE]
uv run --frozen hc ams guardrails delete --environment dev --tenant-id TENANT_ID --guardrail-id GUARDRAIL_ID --confirm
```

- `create` succeeds with `operation: "guardrails.create"` and
  `data.guardrail` = the server's 201 readback. `--name` and
  `--created-by-user-id` are required by the server contract. The tenant comes
  from the saved session's authenticated admin; `--tenant-id` is accepted only
  as an explicit target that must match that session (a mismatch is refused
  before any request). `--rules-file` names an owner-only (0600) regular JSON
  file holding the rules object with string keys. Rules never ride argv (so they
  stay out of the process list) and are never echoed: an unreadable, symlinked,
  non-owner-only, duplicate-keyed or non-object file is refused with one fixed
  message that does not quote its content. Rules are write-only — they are sent
  but never appear in any command output, error message or readback.
- `update` sends only the options you pass (an empty `--description ""` clears
  it; a `--rules-file` holding `{}` sends an empty rule set); it refuses with exit 1
  (`validation`) when no field option is given. `create` treats an empty
  `--description ""` the same way: it is omitted rather than refused.
- `delete` is a reviewed one-shot destructor and requires `--confirm`; without
  it the command exits 2 before login and sends nothing. With it the CLI makes
  exactly one `GET` for the guardrail, one `GET /agents` and one `GET /policies`
  to compute the affected references, prints the resolved target, those
  references and the disclosure block to stderr (pretty mode only; `--output
  json|raw` keeps stderr machine-parsable), sends exactly one `DELETE`, then
  makes exactly one confirmation `GET`. Only a 404 there proves the deletion:
  the command reports `data.deleted: true` and `verification.saved_state:
  confirmed` for a 404, `data.deleted: false` with `saved_state: failed` when
  the guardrail is still served, and `outcome: unknown` with
  `data.mutation_replayed: false` and a `not_read` reconciliation when the
  answer is 5xx or the transport fails. A confirmation read that answers
  401/403 (or any other non-404, non-2xx status) observed nothing about the row,
  so `data.evidence.deleted.confirmation` is `unobserved`, never
  `still_present`. A 2xx answer to the `DELETE` itself (a proxy or misbehaving
  server returning a body) is treated as sent, not as input validation: the one
  confirmation `GET` still decides the outcome. It never replays, retries or
  re-reads on its own, and it never claims a restore: `hc ams restore` replays
  tuning fields onto an existing agent and can never restore a deleted
  guardrail.
- The server guards one reference only: an agent whose `guardrail_ids` contain
  the guardrail answers 409 and nothing is deleted (the CLI reports one
  `conflict` refusal). A policy whose `guardrail_ids` still name it is NOT
  guarded and is not repaired, so a later agent configuration read that resolves
  that policy fails; the disclosure says so, and the printed affected references
  name every policy that still points at the guardrail. When the agent or policy
  catalog cannot be read, the delete is refused (`nothing was changed`) rather
  than executed without its disclosure; the refusal category is the honest one
  (`authentication`, `authorization`, `unsupported` for a missing catalog route,
  `internal` for a server error, `transport` for a network failure).
- Any other 409 on `create` or `update` is one write-worded `conflict` refusal
  that says the server refused the write and to review the guardrail's current
  server state before retrying; no changes were made.
- A 404 for the requested guardrail on `update` is a `not_found` refusal naming
  the ID; a target/tenant mismatch on a write readback is a `validation` refusal
  that reports the landed write truthfully: it names the guardrail and the
  tenant that actually received it and points to `hc ams guardrails get`.
- If the transport times out, the connection drops mid-write, the server
  answers 5xx/408, or a 2xx write body cannot be read as a guardrail, the
  write's outcome is unknown: the CLI reports `outcome: unknown` with
  `data.mutation_replayed: false` and a `not_read` reconciliation, and its
  message says the write may have landed and to check `hc ams guardrails get`
  before retrying. It never re-sends the write on its own. That unknown envelope
  carries the shared `transport` error category for **both** a 5xx/408 answer and
  a network failure (the disclosure reads above use `internal` for a server error
  and `transport` for a network failure; the send-once write helper is shared
  across every catalog domain, so it reports one category for either).
- A drifted installed SDK is refused before the tenant-admin login: the
  preflight checks that `create`, `update`, `delete`, `list` and `get` accept the
  exact call this CLI makes, so a renamed keyword is reported as `unsupported`
  with "Nothing was sent" instead of being mistaken for a landed write with an
  unreadable answer.

Read-only tool catalog listing and inspection are complete and unpaged. The
bounded projection includes only IDs, names, read-only and confirmation flags,
agent types, required scopes, audit events, and activity labels:

```bash
uv run --frozen hc ams tools list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams tools get --environment dev --tenant-id TENANT_ID \
  --tool-id TOOL_ID
```

When both target flags are omitted, these commands may use the matching saved
tenant-admin session. Explicit flags must match that session. Tool output never
includes descriptions, parameters, input schemas, PIP or connector routing, or
arbitrary response fields. The CLI does not execute, assign, create, update, or
delete tools; join agents or skills; or claim assignment, availability,
authorization, deployment, serving, or runtime effectiveness. It makes exactly
one catalog GET after tenant-admin login and does not paginate, retry, cache,
join, or infer additional data. Tool reads refuse target files, root credentials
or base URLs, raw output, blank or dot-segment IDs, and installed SDKs without
both tool methods before tenant-admin login or catalog reads.

Read-only data-source catalog listing and inspection are complete and unpaged.
The bounded projection includes only stable IDs and names:

```bash
uv run --frozen hc ams data-sources list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams data-sources get --environment dev --tenant-id TENANT_ID \
  --data-source-id DATA_SOURCE_ID
```

When both target flags are omitted, these commands may use the matching saved
tenant-admin session. Explicit flags must match that session. Data-source output
never includes descriptions or arbitrary response fields and does not imply
access, patient consent, record availability, assignment, authorization, or
runtime effectiveness. The CLI does not create, update, delete, assign, access
records through, or execute data sources. It makes exactly one catalog GET after
tenant-admin login and does not paginate, retry, cache, truncate, join, or infer
additional data. Data-source reads refuse target files, root credentials or base
URLs, raw output, blank or dot-segment IDs, and installed SDKs without both
data-source methods before tenant-admin login or catalog reads.

Read-only health-service catalog listing and inspection are complete and
unpaged. The bounded projection includes only the stable ID, name, and the
server's created/updated timestamps:

```bash
uv run --frozen hc ams health-services list --environment dev --tenant-id TENANT_ID
uv run --frozen hc ams health-services get --environment dev --tenant-id TENANT_ID \
  --service-id SERVICE_ID
```

When both target flags are omitted, these commands may use the matching saved
tenant-admin session. Explicit flags must match that session. Health-service
output never includes descriptions or arbitrary response fields, and does not
imply booking, availability, pricing, scheduling, or that a service is offered
to a patient. The CLI does not create, update, or delete health services. It
makes exactly one catalog GET after tenant-admin login and does not paginate,
retry, cache, truncate, join, or infer additional data. A `get` whose service ID
is unknown in this tenant reports `not_found` and names the requested ID: a 404
from the route, a null 2xx body, and a record whose tenant does not match the
session all fail without an envelope claiming success (a foreign-tenant record is
a `validation` identity refusal). Health-service reads refuse target files, root
credentials or base URLs, raw output, blank or dot-segment IDs, and installed
SDKs without both health-service methods before tenant-admin login or catalog
reads.

### AMS output modes

The read commands (`hc ams skills|tools|policies|guardrails|data-sources|health-services|teams|departments list|get`
and `hc ams organization hierarchy`) render in two modes (`--output` on the root
`hc` command; `pretty` is the default):

- **`pretty`** (default): a compact human view. Lists print an `ID`/`NAME`
  table with an entry count; a collection whose rows have no `name` (sessions,
  messages, attachments) prints a table of that command's own columns plus the
  count, and `(no entries)` when empty. A single record prints `key: value`
  lines, the organization hierarchy prints an indented tree, and write or
  reconciliation evidence prints labelled `key: value` lines (the write's
  `verification:` block included) instead of JSON. Each failed AMS command
  prints one stderr line, `error: <message> (<category>)` (flag/argument
  usage errors from Click print Click's standard usage message instead).
- **`json`**: the stable result envelope (below) on stdout. When stdout is not
  a terminal, the envelope is printed as one compact line; on a terminal it is
  indented. Errors keep the structured JSON envelope on stderr.

The JSON envelope carries `schema_version`, `operation`, `target`, `outcome`,
and either `data` (succeeded) or `error: {category, message}` (failed). On
read-only operations the envelope no longer includes the `verification` block:
every field there is `not_applicable` on a read, so it was boilerplate. If a
future mutating operation reintroduces it, `verification` reappears only with
non-`not_applicable` content. Everything else about the envelope — field names,
ordering semantics, categories — is unchanged and stays parseable.

`--raw`/`--output raw` stays refused for AMS (`Raw output is refused for AMS.`),
and refusals keep the JSON error envelope on stderr.

```bash
hc ams skills list --environment dev --tenant-id TENANT_ID                 # pretty table
hc ams skills get --environment dev --tenant-id TENANT_ID --skill-id SKILL_ID  # key: value
hc --output json ams skills list --environment dev --tenant-id TENANT_ID   # compact envelope
hc --output json ams skills list --environment dev --tenant-id TENANT_ID | jq .data
```

### What `hc ams` errors report

In `json` and `raw` modes each failed AMS command prints one JSON object on stderr with
an `error.category` and a message naming the real cause; in `pretty` mode the
same message is exactly one stderr line, `error: <message> (<category>)`:

- Local refusals keep their specific message and exit `1` with category
  `validation`: `Raw output is refused for AMS.`, `AMS target does not match the
  saved session; stopped before authentication.`, identity mismatches
  (`Skill/tenant identity mismatch; stopped.`), and dot-segment ID refusals.
- No saved session and no target flags: category `authentication` with
  `Not logged in; run \`hc login --environment <env> --tenant-id <tenant>\`.`
- A `get` whose requested ID does not exist (HTTP 404): category `not_found`
  naming the ID, e.g. `Skill 'nosuch' was not found.` (a 404 on `list` stays
  `validation`; departments keeps its legacy contract for all read statuses,
  while department write 404s are `not_found` refusals naming the ID).
- HTTP 401/403 map to `authentication`/`authorization`; 409 to `conflict`;
  network failures to `transport`; local output failures to `local_io`.
- Any unexpected internal error is category `internal` with a generic message;
  it is never labeled `validation` and never leaks exception details.

```bash
hc --raw ams skills list --environment dev --tenant-id TENANT_ID   # validation: raw refused
hc ams skills list --tenant-id OTHER_TENANT                        # validation: target mismatch
hc ams skills list                                                 # authentication: not logged in
hc ams skills get --skill-id NOSUCH                                # not_found: names the ID
```

Every other command requires an explicit local, non-secret target JSON file.
Only the canonical HTTPS AMS origin for that environment is accepted; custom
origins require separately verified support. Target and nested qualification keys
are strict. Unknown/unavailable qualification may be represented by omitted
records or empty qualification `{}` for inspection/UI, but blocks planning.

The following is a **schema example**, not evidence of deployed CareCo eligibility.
Replace its assertions only after an operator has established the corresponding
proof. `deployed_build` and `proof_ref` are references, never credentials:

```json
{
  "schema_version": 1,
  "environment": "dev",
  "tenant_id": "TENANT_ID",
  "agent_id": "EXPLICIT_SELECTED_AGENT_ID",
  "api_origin": "https://dev-amsservices.health.cloud",
  "qualification": {
    "runtime": {"source": "OPERATOR_VERIFIED_RUNTIME", "proof_ref": "RUNTIME_PROOF"},
    "instructions": {"source": "agent.instructions", "proof_ref": "INSTRUCTION_SOURCE_PROOF"},
    "greetings": {"source": "agent.greetings", "proof_ref": "GREETING_SOURCE_PROOF"},
    "backend_model_context": {
      "deployed_build": "OPERATOR_VERIFIED_DEPLOYED_REVISION",
      "config_version": 7,
      "proof_ref": "FULL_INPUT_AND_WRITER_COVERAGE_REVIEW_PROOF",
      "current_build_confirmed": true,
      "fresh_session": true,
      "no_attachments_or_history": true,
      "full_inputs_approved": true,
      "change_path_without_logs": true
    }
  }
}
```

Qualification is an operator assertion with evidence, never authorization.
Server-side tenant-admin authorization remains mandatory. No current-build API is
established: the CLI cannot automatically detect an unreported deployment, verify
actual runtime consumption, attest deployed all-writer coverage, or audit model
inputs. A changed/unavailable build or agent configuration invalidates the review;
the operator must refresh the record. The recorded config version must equal the
inspected version before planning/apply/restore.
`full_inputs_approved` means an operator approved non-sensitive **full planner and
reviewer inputs**, including custom_tools, catalogs, all agent fields, and all other
server context. Output projection does not sanitize server-side model context.
`change_path_without_logs` requires evidence that the exact deterministic modify
request uses the change path, avoiding the direct-answer branch's recent logs.
Unknown effective sources and file-backed/ignored instructions block tuning.

Before `plan`, `apply` or selected-field `restore`, the operator must review evidence
that the actual serving build includes **all writer version coverage**: ordinary
agent updates and startup/migration repair writers advance `config_version`
atomically with their changes, and tuning apply uses transactional compare-and-set
against the expected version. Bind this coverage evidence and the full-input
review together in the existing `qualification.backend_model_context.proof_ref`
review bundle for its `deployed_build`, inspected `config_version` and
`current_build_confirmed` assertion. AMS source prerequisites
[#488](https://github.com/healthcloudservices/healthcloud-ams-services/pull/488) and
[#497](https://github.com/healthcloudservices/healthcloud-ams-services/pull/497)
are evidence leads; source tests, PR merges, or a numeric `config_version` alone
cannot prove coverage in the serving build. If deployed identity or any writer's
coverage is unknown, or a writer still bypasses version advancement, stop
`plan`/`apply`/`restore`; bounded read-only inspection/UI may continue under their
existing limits. Refresh this evidence after a changed/unknown build or agent
configuration and recheck it before apply and selected-field restore. This is an
operator prerequisite: the current CLI does not automatically attest deployment
or all-writer coverage, and the record adds no server authorization boundary.

For CI and scripts with no saved session, credentials may still enter through
`HC_AMS_ADMIN_EMAIL` and `HC_AMS_ADMIN_PASSWORD` in the current process, intended
for composition with the existing `onepassword-env` wrapper; that compatibility
path logs in once per invocation. There is no fallback identity, secret argument,
arbitrary token import, resolved env file, keychain, or browser credential bridge. Root MCP
`--access-token`, `--base-url`, their environment equivalents, and token/base-URL
profiles are refused for AMS. Root profile environment values do not select AMS.

```bash
uv run --frozen hc ams --target-file TARGET.json inspect
uv run --frozen hc ams --target-file TARGET.json inspect --out PRIVATE_INSPECTION.json
uv run --frozen hc ams --target-file TARGET.json plan --changes-file PRIVATE_CHANGES.json --out PRIVATE_RECEIPT.json
uv run --frozen hc ams --target-file TARGET.json show --receipt PRIVATE_RECEIPT.json --out PRIVATE_DIFF.json
uv run --frozen hc ams --target-file TARGET.json apply --receipt PRIVATE_RECEIPT.json --confirm-plan-hash DISPLAYED_HASH
uv run --frozen hc ams --target-file TARGET.json restore --receipt PRIVATE_RECEIPT.json --out PRIVATE_RESTORE_RECEIPT.json
uv run --frozen hc ams --target-file TARGET.json ui --repo EXISTING_AMS_WORKTREE
```

Changes JSON accepts only nonempty subsets of `instructions` (string) and
`greetings` (language-key to string map). Input changes/receipts must be regular,
owner-only files (for example create under `umask 077`). Outputs use exclusive
0600 creation on POSIX and an owner-only protected DACL on Windows, and refuse
overwrite/symlinks; failed operations remove unfinished
outputs. No whole-agent snapshot is saved. Free text appears only in explicitly
requested private files for human review before any AI use. Default output in
pretty/json/verbose modes contains bounded metadata and hashes; AMS refuses raw.

The planner receives `Update the agent configuration to exactly these field
values: ` followed by sorted, compact requested JSON. Extra fields, files,
validation errors, warnings/skipped references, blocking or unknown review
findings, and approximate wording are refused. Each immutable field change must
include `before` and exact `after` values so a modified receipt cannot redefine a
restore. A server plan omitting `before` cannot safely support this workflow;
review/replan rather than synthesizing server provenance. `show`/`apply` reload the
immutable backend plan/hash. Before apply, recheck the deployed coverage and
full-input qualification above. Apply requires the displayed hash, matching
version and selected before-values, and sends `confirm=True` once. HTTP 401/403
and 409 never trigger identity fallback or mutation replay.

On transport failure or HTTP 5xx, apply records an unknown outcome and reconciles through GET
plan/agent without retrying the mutation. Confirmed saves require applied status,
selected-field readback and exactly the next config version. The current public
response omits publication_status: **saved/readback confirmed is reported separately
from publication unknown and runtime unverified**, with nonzero exit status for
complete success. `publish_failed` is reported separately. Do not replay a saved
apply to resolve publication. A fresh invocation refuses an already consumed plan.

Restore creates a new reviewed plan from only the selected fields' authoritative
before-values against the latest config version. Unrelated edits are preserved;
a current same-field mismatch blocks restore. There is no force restore. Refresh
operator qualification, including deployed all-writer coverage and full-input
evidence, for the latest build/version first, then review the new
restore receipt with `show` and confirm its new hash with `apply`.

UI launch validates the existing `test-client-ui/package.json` Vite script,
refuses lifecycle hooks, and invokes `npm run dev -- --host 127.0.0.1` by argv
with `shell=False`. It installs nothing and launches no backend. The allowlisted
child environment includes toolchain essentials plus only these public target
variables: `VITE_API_URL`, `VITE_HEALTHCLOUD_ENVIRONMENT`, `VITE_TUNING_TENANT_ID`,
`VITE_TUNING_AGENT_ID`. Injected HC/OP credentials and other VITE/NODE/NPM options
are excluded. It prints `/app/agents/{agent_id}`; browser login remains separate.
The supplied existing worktree and its local frontend configuration remain
operator-controlled.

AMS failures exit nonzero with safe categories, never backend exception bodies.
In particular, apply may exit nonzero after a confirmed save because publication
is unknown: inspect the emitted outcome before any next action.

On Windows, `chmod` does not establish file privacy. Create the private-input
directory with an owner-only ACL before saving change JSON or receipts there:

```powershell
New-Item -ItemType Directory -Path .\ams-private
$amsUser = whoami
icacls .\ams-private /inheritance:r /grant:r "${amsUser}:(OI)(CI)F"
```

Native file handling checks the opened handle's owner and DACL, rejects grants to
other principals and reparse points, and creates outputs with their restricted
DACL already in place. Filesystems without usable ACLs fail closed.

The `Python CLI` and `Python CLI (Windows)` CI jobs run the non-live suite on
Linux and Windows, including native file permissions and apply reconciliation.
Both feed `SDK package checks`; a job failure or unexpected job skip blocks that aggregate.
Run locally, sequentially, from `packages/cli/pip` with
`uv run --frozen --extra dev python -m pytest -q -m 'not live'`.
These synthetic checks do not establish live CareCo acceptance. That still needs
an explicitly selected and qualified DEV agent, scoped credentials, and the
attended before/after/restore procedure above.

For an optional real-HTTP reproduction with Postman CLI 1.75.0, start
`postman simulation run postman/simulations/ams-tuning-failure.sim.yaml --port 4900 --no-history --output ndjson`
from the repository root. In another terminal, run the same SDK verification
helper used by the required loopback HTTP tests:

```bash
uv run --project packages/cli/pip --frozen --extra dev python -c "import sys; sys.path.insert(0, 'packages/cli/pip/tests'); from test_ams_http import verify_uncertain_apply; print(verify_uncertain_apply('http://127.0.0.1:4900/ams/api/ams'))"
```

The simulation log must show GET plan, GET agent, one POST apply returning 503,
then GET plan and GET agent. Restart the stateful simulation before repeating;
stop it with Ctrl+C afterward. This is a source-backed synthetic failure fixture,
not the deployed service. The required CI tests exercise both pre-commit and
post-commit 503 over loopback HTTP without requiring a Postman login or installation.


### Installed CLI integration

From the repository root, with Python 3.14, uv 0.12.5 and Node.js available:

```bash
python -m unittest discover -s tooling/cli-integration -p 'test_*.py' -v
python tooling/cli-integration/run.py --output /tmp/hc-cli-integration
```

The runner installs the SDK, tenant-admin SDK and CLI from the frozen CLI lock
with `uv sync --no-editable` into a fresh temporary environment outside the
checkout. It verifies installed module ownership, version, help for every AMS
command, native shell completion, discovery and the
inspect/plan/show/apply/restore journey. Those three local packages keep one
version across the stacked release series, so the install passes
`--refresh-package` for each of them: without it uv can restore a cached wheel
built from an earlier commit and the suite silently exercises old code.

Negative journeys exercise denied login, identity/qualification/plan rejection,
malformed responses, failed readback and uncertain writes without mutation
replay.

The journey suite journals itself as it runs: the output directory holds
`uv/journeys.json` (the ordered journey list), `uv/progress.jsonl` (each
completed journey, flushed) and `uv/journeys/` (per-journey results and
diagnostics). If the suite hits its timeout, the run adds `uv/timeout.json` plus
a console summary naming the journeys that were in flight, so the retained
evidence is never an empty directory that cannot say where the run stopped.

The `Installed CLI integration` workflow selects its uv-installed Linux job at
job level for `packages/cli/**`, `packages/pip/**`,
`packages/pip-tenantadmin/**`, its own tooling/workflow, docs compiler inputs
(`tooling/docs-compiler/**`, `docs-src/**`, `docs/sdk/**`), MCP contracts
(`contracts/mcp/**`), and root package/lock, Python/lock and Ruff inputs. Backend-only changes skip the install job;
the small `CLI integration` result check still runs and verifies applicability.
Invalid selection or an applicable failed/cancelled/skipped job fails it.
The job retains only sanitized consumer/JUnit results for seven days.

All scenario records and credentials are synthetic. A test-only Python startup
adapter preserves the canonical DEV AMS/tenant-admin origins for product
validation while routing only those exact origins over real HTTP to an owned
loopback fixture; other origins are refused before network access. Child
processes use an isolated home and an allowlisted environment. The adapter is
outside all shipped distributions. This boundary does not verify TLS or deployed
service conformance. A verified save still exits 1 with `outcome="saved"` and
`publication="unknown"`; publication and runtime acceptance remain unverified.

Required-check activation is tracked separately in
[services #724](https://github.com/healthcloudservices/healthcloud-services/issues/724).
Require `CLI integration` only after the workflow is merged and both relevant
and unrelated-change Actions results and effective settings are verified.
