# AGENTS.md — Postsale CLI

> **Human handbook + feature docs:** [docs/help/](/help/postsale-cli) (start [docs/help/handbook/01-how-the-cli-works.md](/help/postsale-cli/start/cli-how-the-cli-works)). **Compact agent host supplement:** [docs/help/agent/QUICKREF.md](/help/postsale-cli/agents/cli-agent-quick-reference). **This file remains the runtime agent contract, the single source of truth,** for loop prompts and machine contracts; docs/help must not contradict it.

Postsale is a multi-carrier shipping operations platform. This CLI is designed for autonomous agents — structured JSON output, machine-readable error codes, predictable exit codes, and a natural-language execution layer.

## Quick start

```bash
# Authenticate
postsale auth login          # browser-based (PKCE)
postsale auth login --no-browser  # device flow (no browser needed)
# or: export POSTSALE_TOKEN=<your_token>   # a login token, or an API key (psk_…) from the app

# Natural language execution (uses Claude)
postsale run "ship all pending orders from today with the cheapest carrier"
postsale run "how much did we spend on shipping last month?"
postsale run "get rates for shipment shp_abc123"
```

## The `run` command — what agents should use

`postsale run "<intent>"` is the primary agent interface. It interprets a natural language intent and executes the right sequence of Postsale operations automatically.

```
postsale run "<intent>" [--yes] [--provider <name>] [--model <model>] [--max-turns <n>] [--stream]
```

- `--yes` — allow money-moving (label purchase, shipment creation, void) and destructive (deletes, end-of-day manifest close) operations without pausing
- `--provider` — `anthropic` or `openai` (default: auto-detect from env)
- `--model` — provider-specific model (default: provider's default — `claude-opus-5` for Anthropic, `gpt-4o` for OpenAI)
- `--max-turns` — cap on agent loop iterations (default: 20)
- `--stream` — emit NDJSON event stream on stdout instead of a single envelope at the end

Requires one of `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` in environment. If both are set and no `--provider` is passed, Anthropic is auto-selected silently — no per-run stderr emission. To inspect or verify the active provider, run `postsale doctor`: it returns a structured `llm_provider` check with `active`, `model`, `source`, `available`, and (when both keys are set without an explicit override) a `warn` status with a message naming the override knobs. Override with `--provider openai` or `POSTSALE_LLM_PROVIDER=openai`.

### `postsale run` output contract

**Default (single envelope on completion):**

```json
{
  "summary": "You have 3 carriers: USPS, FedEx, UPS.",
  "data": { /* last successful tool result */ },
  "tool_calls": [
    { "name": "get_carriers", "input": {}, "output": [...], "is_error": false, "duration_ms": 234 }
  ],
  "turns": 2,
  "stop_reason": "end_turn",
  "provider": "anthropic",
  "model": "claude-opus-5",
  "warning": "max_turns_reached"  // only present if loop hit max_turns or unexpected stop
}
```

**`--stream` (NDJSON events as they happen):**

```json
{"event":"tool_call_start","id":"call_a","name":"get_carriers","input":{}}
{"event":"tool_call_end","id":"call_a","name":"get_carriers","output":[...],"is_error":false,"duration_ms":234}
{"event":"text","text":"You have 3 carriers..."}
{"event":"complete","summary":"...","data":{...},"tool_calls":[...],"turns":2,"stop_reason":"end_turn","provider":"anthropic","model":"claude-opus-5"}
```

If aborted mid-stream (Ctrl-C): emits `{"event":"aborted"}` then exits 130.

**stdout is pure NDJSON under `--stream`:** every stdout line is exactly one JSON event object (`event` is always present) — pipe it straight to `jq -c .` line by line. All human-facing output — narrative text, the `→`/`✓` tool breadcrumbs, and the progress spinner — goes to **stderr**, so progress never interleaves into the event stream. For a machine-only feed, redirect stderr (`postsale run --stream "…" 2>/dev/null`). This purity is enforced by a test (loop.test.ts, "every stdout line is a JSON event").

**stdout is envelope-only in non-stream mode too — no `--json` flag is needed (or provided).** A non-interactive `postsale run "…"` writes at most ONE line to stdout: the completion envelope, emitted on every handled outcome (success, gated abort, first Ctrl-C, LLM error, max-turns). Everything human-facing — per-turn narrative, tool breadcrumbs, the spinner, resume hints, and confirmation prompts (rendered on stderr even at a TTY) — goes to stderr in every mode, so `postsale run "…" > out.json` always captures clean JSON. Pre-flight failures (missing LLM credential, malformed configuration, unknown `--continue` conversation) produce EMPTY stdout plus a structured error on stderr and a non-zero exit — treat empty stdout with non-zero exit as "no envelope was produced". Exceptions: `run --examples` prints a bare JSON array (not an envelope), and `run --interactive` / `postsale continue` emit one envelope per user turn. Enforced by a test (loop.test.ts, "non-stream stdout purity").

### Multi-agent swarming (`parallel_map` tool)

The agent has a `parallel_map(template_intent, items, options?)` tool to fan out to N subagents in parallel. Use when you have a list of independent items (orders, shipments) and want the same multi-step operation on each — typically when N >= 3.

```bash
postsale run "ship all 50 pending orders today with the cheapest carrier" --yes
# Parent searches orders, then calls parallel_map with template_intent and the 50 items.
# Each subagent (concurrency 5, max 10) handles one order: get rates, create shipment, purchase label.
# Aggregated result: { successful: [...], failed: [...], count: { total, successful, failed } }
```

**Safety: batch authorization (NOT --yes).** When `parallel_map` would perform money-moving operations (create_shipment / purchase_labels / void_shipment), the user is prompted to authorize the batch BEFORE any subagent runs — even when `--yes` was passed. The `--yes` flag authorizes the parent's own tool calls; it does NOT extend to swarm fan-out (a 50-item batch exceeds the original intent's scope). The executor refuses money-moving in subagents without an explicit batch authorization token.

**Read-only swarms and keyword false positives.** The batch gate is triggered by a text scan of `template_intent`, and the scan is deliberately over-sensitive: any mention of a money-moving tool name or a money-adjacent word (`ship`, `fulfill`, `dispatch`, `send out`, `charge`, `buy`, `pay`, `pay for`, `purchase`, `label`, `shipment`, `tracking number`, `void`, `refund`) trips `swarm_authorization_required` — even when every subagent call is a read, and non-TTY runs then fail closed. This bias is intentional (a missed money batch is worse than a re-worded read batch); do not treat it as a bug or seek a gate bypass. Note that `options.money_moving: false` does NOT suppress the scan — only `true` is consulted, and the scan remains authoritative regardless. The safe pattern for read-only batches is a template that avoids the trigger words: "look up each order and report its current status", not "check each shipment's label status" (verified live 2026-07-13: trigger-word-free `get_order` subagents run without the gate).

**Idempotency.** Per-subagent tool calls use deterministic Idempotency-Keys derived from `(batchId, subagentIndex, tool, input)` where `batchId = sha256(intent + sorted_items + accountId)`. Re-running the same `postsale run "ship orders [a,b,c]"` after a crash reuses the same keys, so retries are attributable in traces and logs. `purchase_labels` buys one shipment per request (`POST /v2/shipment/labels`) and derives one key per shipment from the batch input plus that shipment id, so a retried batch reuses each shipment's key and two batches that share a shipment dedup on it. The backend does NOT deduplicate on the header (neither the order nor the shipment service reads it, verified against origin/main 2026-09-01); what stops a double purchase is shipment state. A purchase against a shipment that is already `processed` is NOT refused: it answers 200 with the labels that shipment already has, so a retried purchase_labels looks exactly like a fresh purchase — compare the returned label ids / tracking numbers with what you already recorded before reporting a new purchase. Only a purchase still running for the same shipment is reported in `errors`. Shipment CREATION is not protected either way: a retried create_shipment produces a second shipment, which is why the tool tells you to check for an existing one first.

**Hard caps.** `items.length ≤ 50`, `concurrency ≤ 10` (default 5), `max_turns_per_subagent ≤ 12` (default 8). Destructive tools (all `delete_*` plus `create_end_of_day_manifest`) are excluded from the subagent tool set entirely — there is no batch-authorization path for destructive work; it runs only in the parent loop under the normal gate.

**Streaming events** (with `--stream`):

```json
{"event":"subagent_start","index":0,"input":{"order_id":"ord_a"}}
{"event":"subagent_end","index":0,"status":"success","summary":"Created shipment shp_abc and purchased label for $8.42."}
{"event":"subagent_draining","index":3,"summary":"purchase_labels in flight"}  // shown when SIGINT mid-flight on money-moving
```

**Non-TTY contexts** (CI, automation): batch authorization fails with exit 64 unless `POSTSALE_SWARM_AUTOAUTH=1` is set, in which case authorization is bypassed and a structured warning is emitted to stderr for audit. Use only in pre-authorized contexts.

### Intent-shaped write tools (create_order, create_shipment, get_rates)

These three write tools accept **intent-shaped args**, not raw API bodies. The CLI applies commercially-neutral defaults and validates locally before sending — agents don't need to memorize the strict Postsale API shape. Example for create_order:

```json
{
    "buyer": { "name": "Jane Doe", "email": "jane@example.com" },
    "ship_to": {
        "street_line_1": "123 Main St",
        "city": "Springfield",
        "state_province": "IL",
        "postal_code": "62701",
        "country_code": "US"
    },
    "same_as_shipping": true,
    "items": [{ "sku": "WIDGET-1", "name": "Widget", "quantity": 1, "price": 9.99, "weight": 0.5, "weight_unit": "lbs" }]
}
```

Example for create_shipment — pin a carrier type and service, and buy a test label:

```json
{
    "order_id": "550e8400-e29b-41d4-a716-446655440000",
    "carrier": "usps",
    "service": "usps_priority_mail",
    "test_label": true,
    "packages": [{ "weight": 1, "weight_unit": "lbs", "length": 8, "width": 6, "height": 4, "dim_unit": "in" }]
}
```

`carrier` / `service` are the backend's own field names and values (`fedex` | `ups` | `usps`; a service code from `get_carriers` → `enabled_services`). A carrier ACCOUNT cannot be pinned by id on this endpoint — the type resolves to your default account of that type, or to the built-in (house) USPS account when you have none. `test_label: true` buys watermarked carrier test-environment labels that are never billed (USPS incl. the house account, and UPS; other carriers refuse); store automation still runs for them and the purchase is still gated as money-moving. When the host sets `POSTSALE_TEST_LABEL_MODE` (any non-empty value), the CLI stamps `test_label: true` onto every shipment it creates and discloses it in `_cli_meta.applied_defaults`; an empty `create_shipment` body is refused (`test_label_mode_requires_body` — a rules-built shipment cannot carry the flag, so pass `carrier`, `service` and `packages`), `test_label: false` is refused (`test_label_mode_conflict`), and `purchase_labels` reads every shipment first and refuses the whole batch with `test_label_required` unless each is a test shipment — nothing is bought on a refusal, and the result of a successful purchase carries `_cli_meta.test_label_verified`. The mode is a constraint, not authorization: every gate applies exactly as without it. USPS label sender/return-to addresses are NOT shipment inputs: they live on the USPS carrier account's `settings.senderAddress` / `settings.returnAddress` (set with `update_carrier`) and are snapshotted onto the processed shipment as `sender_address` / `return_address`.

**Two safety rules the builders enforce (no override possible):**

1. `weight_unit` MUST be supplied alongside any `weight` value. No default — wrong unit changes carrier rates 2-30×.
2. `bill_address` MUST come from `same_as_shipping: true` OR `advanced.bill_address`. Never silently fabricated.

**Override path:** the `advanced` parameter accepts arbitrary keys that get merged into the API body AFTER builder defaults. Top-level keys REPLACE entirely (no nested deep-merge); arrays REPLACE rather than append. AJV validates the final body against the resolved swagger before any network call — local errors bubble back to the agent in a single turn.

**Response wrapping:** responses include `_cli_meta.applied_defaults` — an array of `{field, value, reason}` showing what got auto-filled. Surface these in user summaries.

**get_rates dimension estimate:** a weight-only package (no length/width/height) gets a documented small-parcel ESTIMATE default — `6 x 6 x 6 in`, or `15 x 15 x 15 cm` when `dim_unit: cm` was supplied — surfaced per-field in `applied_defaults`. It exists so a rate quote succeeds in one turn; the quote may be low for larger boxes (dimensional weight), and the estimated dimensions must never be reused for `create_shipment` or a purchase. Supplying only some of length/width/height fails fast locally with `package_dimensions_incomplete` — provide all three or none.

**Schema introspection for any tool:** call `get_tool_schema(tool_name)` to get the full resolved swagger schema (with `$ref` pointers inlined) for any builder-backed write tool.

### The work queue (`get_work_queue` / `postsale orders work-queue`)

"What should I ship?" has a product-defined answer — agents should call `get_work_queue` rather than guessing status enums (status names vary by account, and a wrong guess fails silently: empty-looking accounts or four-year-old samples presented as today's work). The same definition is available **without an LLM key** as the direct subcommand `postsale orders work-queue` (flags: `--window-days`, `--all-time`, `--statuses`, `--include-samples`, `--page`, `--page-size`, `--human`) — it is a thin wrapper over the tool, not a second implementation, so the two surfaces cannot disagree. Caller-fixable input errors (`invalid_window_days`, `unknown_order_status`, `work_queue_statuses_unresolved`) exit 64 there. The definition, confirmed 2026-07-14:

- **Statuses:** resolved from the account's own status list, matching the curated ready set (`ready to ship` / `awaiting shipment` / `unfulfilled`, exact normalized matches). No match → a loud error listing the account's statuses; pass `statuses: [...]` explicitly after confirming with the user.
- **Recency:** within `window_days` (default 60) by `order_date`. Full history: `all_time: true` (there is no zero-day window). Windowed on `order_date` (always populated) rather than created/imported timestamps, which aren't indexed until an order is updated — trade-off: a recently-imported historical order with an old `order_date` is not counted as fresh work until that backend gap is fixed.
- **Sort:** newest first (`order_date` descending).
- **Samples:** orders whose number contains `CSV-SAMPLE` are excluded by default (`include_samples: true` to keep them).
- **Disclosure:** the result's `applied` block states every filter, and `excluded` carries counts for `outside_window`, `outside_resolved_statuses` (in-window work in other/blank statuses — reachable via `search_orders` without a status filter), and `samples_in_window`. Surface these in summaries.

Equivalent structured filter for direct `search_orders` use: `conditions: { operator: "AND", items: [ { field: "order_status", operator: "in", value: [<resolved statuses>] }, { field: "order_date", operator: "greater_than_or_equals", value: "<cutoff YYYY-MM-DD>" }, { field: "order_number", operator: "not_includes", value: "CSV-SAMPLE" } ] }` with `order_by: "order_date"`, `order_by_direction: "descending"`. (The window is on `order_date` — the always-populated field; `order_created_at`/`order_first_imported_at` are not written to the search index until an order is updated, so never-touched orders fall out of a window on those.)

### Empty search results (`search_orders`)

A `search_orders` call that matches **zero orders** returns `_cli_meta.empty_result_context` alongside the raw response: `mode` (`natural` | `structured` | `unfiltered`), `applied` (the filter as sent), `filter_fields` (the field names the effective filter used — for natural-language queries taken from the backend's parse, which the raw response carries in `interpreted_query`), and `alternates` with `account_total_orders` (no filter), `same_filter_without_status` (the same filter with `order_status` conditions removed; `null` when there were none), and `account_statuses` (the account's status vocabulary). Agents must read this before reporting "no work": a nonzero alternate count means the filter missed, not that nothing exists. Caveats: alternate counts degrade to `null` when a probe fails; all counts exclude archived orders unless the filter includes an `order_archived` condition; an empty page past the end of a matching search is pagination, not an empty result, and carries no context block. This contract applies to the agent tool — the direct CLI `orders search --query` passes the raw natural-search response through (its `interpreted_query` is already visible there).

### Example intents

```
postsale run "find all orders shipped last week to California"
postsale run "get rates for shipment 019a4f2c-7b31-7d5e-9f04-2c8e6b1a4d77"
postsale run "how many orders were shipped this month?"
postsale run "ship order ord_abc123 with the cheapest carrier" --yes
postsale run "show me our top 5 shipping carriers by cost this quarter"
postsale run "create a shipment for order ord_xyz and purchase the label" --yes
```

## Direct commands (when you know exactly what you want)

### Output conventions

- **stdout**: JSON (default) or pretty-printed JSON (`--human`)
- **stderr**: structured error envelopes `{ error: { code, message, details?, request_id?, retry_after? } }`
- **Pagination**: add `--all` to stream all results as NDJSON (one JSON object per line)
- **Context cost**: `orders search` accepts `--compact`, projecting each order to `id`, `number`, `status`, `date`, `total`, `ship_to` (`"City, ST"`), and `items` as a **count**. A 50-order page drops from ~55KB (~13.7k tokens) to ~8.5KB (~2.1k) — **~6.4x**. Applies to `--all` NDJSON too. Response-level fields survive, including `_cli_meta.empty_result_context`. Use it when triaging (counting, filtering, picking an id); omit it when you are about to act, since `create_shipment` needs the full destination address and `get_rates` needs item weights. `postsale orders get <order_id>` refetches the full record for anything picked out of a compact list.

### Exit codes

| Code | Meaning                                                                   |
| ---- | ------------------------------------------------------------------------- |
| 0    | Success                                                                   |
| 64   | Usage error (bad flags, confirmation required without --yes)              |
| 65   | Data format error (bad JSON input)                                        |
| 66   | Input file not found                                                      |
| 70   | Internal CLI error                                                        |
| 71   | OS error (keyring and similar system facilities)                          |
| 75   | Network / temporary failure                                               |
| 77   | Permission denied (403)                                                   |
| 78   | Configuration error / operation allowlist refusal                         |
| 100  | Auth required or failed (401)                                             |
| 101  | Subscription inactive (402)                                               |
| 102  | Validation error (422) — check `error.details`                            |
| 103  | Rate limited (429) — check `error.retry_after` seconds                    |
| 104  | Not found (404) — verify the id with a search first                       |
| 105  | Bad request (other 4xx)                                                   |
| 106  | Request timeout — backend hung; retry or raise `POSTSALE_HTTP_TIMEOUT_MS` |

### Safety model

Every operation has a `safetyClass`. The CLI enforces these:

| Class              | Meaning                     | `--yes` required? |
| ------------------ | --------------------------- | ----------------- |
| `read`             | Safe, retryable¹            | No                |
| `idempotent-write` | State change, not retryable | No                |
| `destructive`      | Permanent deletion          | Yes               |
| `money-moving`     | Charges carrier account     | Yes               |

¹ A small number of `read` operations are deliberately **not** auto-retried
because the backend handler embeds a conditional write (currently the two
shipments-by-order lookups, `POST /v1/shipments` and `GET
/v1/shipments/{reference}`, which can validate-and-persist a shipment's
address server-side; see `operations-overrides.ts`). They are still safe to
call — only the automatic replay on transient failure is suppressed.

Non-interactive callers (no TTY) MUST pass `--yes` for `money-moving` and `destructive` operations, or receive exit 64 with `{ error: { code: "confirmation_required" } }`.

Most `idempotent-write` subcommands also accept `--yes` for interface uniformity, but it is a no-op there — they never prompt and never require it. A few do not declare it at all (e.g. `orders recover`, `templates move`, `templates folders create|update`); check `--help` before passing it. Bulk operations (e.g. `orders bulk-status`) prompt regardless of class because their scope exceeds a single entity.

#### `--dry-run` on `postsale run`

`postsale run --dry-run "<intent>"` simulates the highest-risk tools instead of executing them. Under `--dry-run`:

- **Simulated (no API call, no confirmation, no `--yes` needed):** money-moving (`create_shipment`, `purchase_labels`, `void_shipment`), all destructive tools (`delete_*`, `create_end_of_day_manifest`), and the order-status writes (`set_order_status`, `bulk_set_order_status`). Each returns a `{ "dry_run": true, "tool": "<name>", "note": "…was NOT executed…", "input_received": {…} }` envelope. Because nothing money-moving or destructive can actually happen, these tools do **not** hit the confirmation gate — a non-interactive `run --dry-run` never returns `confirmation_required` for them, and no carrier API is called. One exception to "no API call": under `POSTSALE_TEST_LABEL_MODE`, `purchase_labels` still reads every shipment live (the same `GET /v1/shipment/{id}` that `get_shipment` makes, address-validation side effect included) before simulating, so a dry-run previews the `test_label_required` refusal a real run would produce; the simulated envelope then carries `test_label_verified` and says so in its `note`.
- **Still executed for real:** other writes (`create_order`, `update_order`, `update_shipment`, tagging, bulk field edits, filter create/update) are reversible record edits and are **not** intercepted — they run against the API even under `--dry-run`. Account for that before calling them.
- **`parallel_map`:** a money-moving swarm still requires batch authorization even under `--dry-run`, because a subagent may perform one of the still-executed writes above; the batch gate stays fail-closed.
- **Audit record:** every intercepted call is listed in the envelope's top-level `simulated_effects` array as `{ tool, operation_key, input_received }` (subagent interceptions carry `subagent_index`), while `confirmed_effects` stays limited to real writes. `simulated_effects` documents what `--dry-run` blocked **as received** — interception happens before input validation and idempotent dedup, so a real run may reject or dedup some listed calls, and delete-class tools never produce `confirmed_effects` counterparts. Outside `--dry-run` the array is present and empty.

### Error recovery guide for agents

| `error.code`                            | Action                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `auth_required`                         | Run `postsale auth login` or set `POSTSALE_TOKEN`                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `api_key_unsupported`                   | The active credential is an API key (`psk_…`) and this operation needs a login session: the user profile (`get_account` / `account whoami`), automations, templates, and key/payment/subscription management refuse keys by backend policy. Not retryable on the key (exit 100); surface to a human or skip the step                                                                                                                                                                                         |
| `api_key_rejected`                      | The API key was refused. With `error.missing_scopes` present, the owner grants those permissions to the key in the Postsale app (Settings → Integrations → API Keys; effective within about a minute). Without it, the key is unknown, revoked, disabled, or the plan no longer includes keys. Never "log in" and never retry as-is (exit 100)                                                                                                                                                               |
| `subscription_inactive`                 | Account payment issue — surface to human                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `validation_failed`                     | Check `error.details` for field-level errors, fix input                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `rate_limited`                          | Wait `error.retry_after` seconds, then retry                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `confirmation_required`                 | Check `data.gate` on the run envelope: `direct` → retry with `--yes`; `swarm_size` → interactive TTY approval only (no flag bypass)                                                                                                                                                                                                                                                                                                                                                                          |
| `swarm_authorization_required`          | Money-moving swarm batch gate. `--yes`/`--authorize` do NOT apply: approve interactively at a TTY, or set `POSTSALE_SWARM_AUTOAUTH=1` in a pre-authorized environment. A read-only batch can trip this via template wording (`shipment`, `label`, `ship`, …) — reword the template to avoid money keywords instead of seeking a bypass                                                                                                                                                                       |
| `user_denied` / `swarm_aborted_by_user` | A human explicitly declined the prompt. Do not retry or suggest bypasses without new instructions                                                                                                                                                                                                                                                                                                                                                                                                            |
| `terms_acceptance_required`             | Owner-action gate (`data.gate: "owner_action"`). Buying a house/USPS (built-in) or Shipsurance label needs the ACCOUNT OWNER to accept carrier terms. You CANNOT do this. Check `data.accept_channel`: `"hosted_page"` → surface `data.accept_url` (a browser page); `"in_app"` (Shipsurance today) → surface `data.remediation` (accept in the Postsale menubar app or Chrome extension). Tell the owner to accept, then STOP. Do not accept terms yourself, do not retry until they confirm. Exit code 78. |
| `subscription_reactivation_required`    | Owner-action gate (`data.gate: "owner_action"`). The account subscription is inactive, so the backend refuses every billable action (create order/shipment, buy label). You CANNOT fix this — surface `data.reactivate_url` to the human owner, tell them to reactivate, and STOP. Do not retry until they confirm. Exit code 101.                                                                                                                                                                           |
| `operation_not_in_v0_allowlist`         | Operation blocked by safety gate; check `error.safety_class`                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `network_error`                         | Transient — retry after brief wait                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `request_timeout`                       | Backend hung past the timeout; retry, or raise `POSTSALE_HTTP_TIMEOUT_MS`                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `update_channel_closed`                 | `postsale upgrade` / `--check-update`: the public release channel is not open yet. Not retryable; new versions arrive by invitation (exit 78)                                                                                                                                                                                                                                                                                                                                                                |
| `not_found`                             | ID doesn't exist; verify with a search first                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |

On a gated abort, the `run` envelope's `data` is `{ blocked: true, code, tool, gate, next }` — `gate` names which gate blocked (`direct` | `swarm_batch` | `swarm_size`) and disambiguates codes that multiple gates share; `next` lists only remedies that actually apply to that gate (empty after an explicit human deny). This covers `parallel_map` as well as the direct money-moving/destructive tools.

**Owner-action gate (`gate: "owner_action"`).** Distinct from the confirmation/authorization gates above: those an agent can satisfy with a flag or a TTY prompt, but an owner-action gate can ONLY be resolved by the human account owner in a browser — never by the agent, and never with a flag. Two exist today. (1) `terms_acceptance_required`: purchasing a house/USPS built-in or Shipsurance label returns `data = { blocked: true, code: "terms_acceptance_required", gate: "owner_action", terms_type, required_version, accept_channel, accept_url, remediation, affected_shipment_ids, next }`. `accept_channel` says HOW the owner accepts: `"hosted_page"` → `accept_url` is a hosted browser page where they review and accept that version (built_in_labels today) and `remediation` is null; `"in_app"` → `accept_url` is null and `remediation` tells them to accept in the Postsale menubar app or Chrome extension (Shipsurance today has no hosted page). `affected_shipment_ids` are the shipments this blocked (others in the batch may have purchased and appear in `confirmed_effects`). Relay whichever of `accept_url` / `remediation` is non-null, tell them acceptance is a one-time per-version step, and STOP. (2) `subscription_reactivation_required`: when the account subscription is inactive the backend 402s every billable request, so any of create order/shipment or label purchase returns `data = { blocked: true, code: "subscription_reactivation_required", gate: "owner_action", tool, reactivate_url, next }` (exit 101). Relay `reactivate_url` to the owner and STOP. For BOTH: never fabricate acceptance/reactivation, never call an accept/reactivate endpoint, never retry until the owner confirms. Account creation is the same doctrine — browser-only, human-only (it includes agreeing to Postsale's terms); the CLI never signs a human up automatically.

---

## Command reference

### Orders

```bash
postsale orders work-queue                                  # what should I ship? (product definition)
postsale orders search --query "shipped last week"          # natural language
postsale orders search --json '{"status":"awaiting_shipment"}'  # structured filter
postsale orders search --query "pending" --all              # stream all as NDJSON
postsale orders search --query "pending" --compact          # ~6.4x smaller rows (see below)
postsale orders get <order_id>
postsale orders create --input order.json
postsale orders update --input order.json
postsale orders set-status <order_id> --status shipped
postsale orders bulk-status --order-ids id1 id2 --status shipped --yes
postsale orders tag <order_id> --key priority --value urgent
postsale orders recover <order_id>
postsale orders bulk-delete --order-ids id1 id2 --yes
postsale orders create-shipment <order_id> [--input body.json] --yes
```

### Shipments

```bash
postsale shipments list                                     # all shipments (orders that have shipments, flattened)
postsale shipments list --query "shipments processed this month"   # natural-language order filter
postsale shipments list --all                               # stream all shipment rows as NDJSON
postsale shipments get <shipment_id>
postsale shipments get --reference <order_id>               # all shipments belonging to one order
postsale shipments get-rates --input shipment.json          # compare rates without committing
postsale shipments create --input shipment.json --yes       # money-moving
postsale shipments update <shipment_id> --input body.json
postsale shipments delete <shipment_id> --yes
postsale shipments bulk-update-package --shipment-ids id1 id2 --input body.json --yes
postsale shipments bulk-update-service --shipment-ids id1 id2 --service fedex_ground --yes
postsale shipments bulk-update-ship-date --shipment-ids id1 id2 --ship-date 2026-05-01 --yes
postsale shipments bulk-update-from-address --shipment-ids id1 id2 --input body.json --yes
```

#### Working with shipments — data model and enumeration paths

Shipments live in a separate service from orders, joined by one convention: **a
shipment's `reference` field holds its parent order's id** (assigned at creation).
Two consequences shape every shipment workflow:

- **Order-search responses do not embed shipments.** Shipment-scoped search
  conditions (`shipment_status`, `shipment_processed_date`, `shipment_carrier`, …)
  correctly FILTER which orders match, but the returned order records carry no
  shipment data. There is also no backend endpoint that enumerates shipments
  directly at shipment grain.
- **Shipment records come from the shipments-by-orders lookup** (`POST
/v1/shipments` with `{ order_ids }`, and its single-order form `GET
/v1/shipments/{reference}` where `reference` is an order id — distinct from the
  user-set `reference_1/2/3` fields). Note these lookups are not pure reads on
  the backend: for US unprocessed shipments lacking address validation, the
  handler validates and persists the address (emitting shipment-updated
  automation events). The CLI therefore classifies them `read` but never
  auto-retries them.

Supported enumeration paths, in order of preference:

1. **`postsale shipments list`** — the full pattern in one command: searches
   orders (natural-language `--query`, structured `--input`/`--json`, or the bare
   default "orders that have shipments"), fetches their shipments, and emits
   shipment-grain rows annotated with `order_id` and `order_number`.
   `--page`/`--page-size` operate at ORDER grain (inherited from the backing
   search); `--all` streams shipment rows as NDJSON and `--max-items` caps the
   orders scanned.
2. **`postsale shipments get --reference <order_id>`** — all shipments belonging
   to one order, as a JSON array.
3. **Custom pipelines** — compose the same two steps yourself when you need
   different shaping:

    ```bash
    postsale orders search --query "orders from California last week" \
      | jq -r '.orders[].id' \
      | xargs -n1 postsale shipments get --reference
    ```

4. **The agent (`postsale run`)** — follows the same pattern with its own tools:
   `search_orders` finds the orders (shipment-scoped conditions filter at order
   grain), then `list_shipments_for_orders` with the matching order ids fetches
   the shipment records (each annotated by its `reference` field — the parent
   order id). Counting goes through `get_report` (`shipment_count`).

The shipments-by-order-ids endpoint backs both surfaces: it is the internal join
step of `shipments list` and the `list_shipments_for_orders` agent tool. A
separate by-order-ids subcommand was declined as redundant with order-id-targeted
search (decision 2026-06-11).

### Labels

```bash
postsale labels purchase --shipment-id <shipment_uuid> <shipment_uuid> --label-type pdf --yes  # money-moving; ids are shipment UUIDs, at most 10
postsale labels void --shipment-id <shipment_uuid> --yes                                        # money-moving
```

### Reports (all use natural language filtering with --query)

```bash
postsale reports order-count [--query "last 30 days"]
postsale reports shipment-count [--query "this month"]
postsale reports revenue [--query "Q1 2026"]
postsale reports top-items [--query "last quarter"]
postsale reports cost-by-carrier [--query "last week"]
```

### Carriers

```bash
postsale carriers list
postsale carriers add --input credentials.json
postsale carriers update --input credentials.json
postsale carriers delete <id> --yes
postsale carriers validate --input credentials.json         # test credentials without saving
```

`carriers update` takes a carrier object (or an array) with `id` plus only the keys you are changing; the CLI reads the current account and merges onto it before calling `PUT /v1/carrier`, so you never have to supply the credential fields `carriers list` redacts and they are never cleared (`settings` merges key by key; `null` on a settings key clears it); `status` and `carrier_type` are never taken from the caller (removing an account is `carriers delete`, gated); the printed result is redacted like `carriers list`. USPS accounts carry two optional label addresses in `settings` — `senderAddress` (business/shipper block on the label) and `returnAddress` (return-to block for undeliverable packages); blank means the ship-from address is used. A `built_in: true` entry is the Postsale-operated house USPS account: you own only `address`, `default`, and those two settings keys on it; it cannot be deleted; pickup scheduling is refused on it; label purchases on it need the house-labels feature, the owner's one-time terms acceptance (owner-action gate), and a payment method, except for test labels. Status `needs_reauth` means the carrier rejected the stored credentials and the account must be reconnected in the web app. See [docs/help/commands/carriers.md](/help/postsale-cli/commands/cli-carriers).

### Automation

```bash
postsale automation workflows list
postsale automation workflows get <id>
postsale automation workflows create --input workflow.json
postsale automation workflows update <id> --input workflow.json
postsale automation workflows delete <id> --yes
postsale automation workflows configure --prompt "auto-assign fedex ground for orders over 1lb"

postsale automation webhooks list
postsale automation webhooks get <id>
postsale automation webhooks create --input webhook.json
postsale automation webhooks update <id> --input webhook.json
postsale automation webhooks delete <id> --yes
postsale automation webhooks rotate-secret <id> --yes
```

### Templates

```bash
postsale templates list
postsale templates get <id>
postsale templates create --input template.json
postsale templates update <id> --input template.json
postsale templates delete <id> --yes
postsale templates move <id> --folder-id <folder_id>
postsale templates folders list
postsale templates folders create --name "My Folder"
postsale templates folders update <id> --name "New Name"
postsale templates folders delete <id> --yes
```

### Origin addresses & shipping rules

```bash
postsale origin-addresses list
postsale origin-addresses create --input address.json
postsale origin-addresses update <id> --input address.json
postsale origin-addresses delete <id> --yes

postsale shipping-rules list
postsale shipping-rules create --input rule.json
postsale shipping-rules update <id> --input rule.json
postsale shipping-rules delete <id> --yes
```

### Saved filters

```bash
postsale filters list
postsale filters create --input filter.json
postsale filters update <id> --input filter.json
postsale filters delete <id> --yes
```

### Introspection & diagnostics

```bash
postsale schema orders.search                # OpenAPI request/response schema
postsale schema --list                       # all available command slugs
postsale account whoami                      # current user profile
postsale account jwt-scopes                  # JWT permissions claimed (advisory; not authoritative)
postsale account env                         # active environment (no network)
postsale doctor                              # token validity, API reachability, latency
```

### Auth

```bash
postsale auth login                          # browser PKCE flow
postsale auth login --no-browser             # device flow (for headless agents)
postsale auth status                         # token info
postsale auth logout
```

---

## Input conventions

| Method               | When to use                               |
| -------------------- | ----------------------------------------- |
| `--query "text"`     | Natural language for search/filter/report |
| `--input <file>`     | Complex JSON body from file               |
| `--input -`          | JSON body from stdin (pipe-friendly)      |
| `--json '{"k":"v"}'` | Inline JSON body                          |

## Environment variables

| Variable                        | Purpose                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POSTSALE_TOKEN`                | A login access token or an API key (`psk_…`, provisioned in the Postsale app under Settings → Integrations → API Keys) — alternative to `postsale auth login`. A stored login session takes precedence; when it shadows a key, every command that calls the API prints one `env_credential_ignored` warning. On a key: `auth status` reports `credential_kind: api_key`, `doctor` probes with a scoped read, key-refused operations answer `api_key_unsupported`, a missing scope answers `api_key_rejected` with `missing_scopes`        |
| `POSTSALE_ENVIRONMENT`          | `production` (default). Other values are reserved for Postsale internal use                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `POSTSALE_DEBUG`                | Set to `1` for verbose HTTP logging                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `POSTSALE_HTTP_MAX_CONCURRENCY` | Maximum in-flight backend HTTP attempts, process-wide (default 5; integer >= 1, malformed values abort with `configuration_error`/exit 78). Applies per attempt across agent turns, swarm subagents, pagination, and direct commands — a single direct call never waits                                                                                                                                                                                                                                                                   |
| `POSTSALE_HTTP_RPM`             | Sustained per-minute backend HTTP attempt budget (default 120, burst capacity rpm/4; integer >= 1, malformed values abort with `configuration_error`/exit 78). Retries consume budget; the concurrency cap is the primary fan-out bound; this bounds the sustained request rate                                                                                                                                                                                                                                                           |
| `POSTSALE_HTTP_TIMEOUT_MS`      | Per-request HTTP timeout in milliseconds (default 30000). A request that exceeds it aborts with error code `request_timeout` (exit 106); GET-only retry behavior is unchanged                                                                                                                                                                                                                                                                                                                                                             |
| `ANTHROPIC_API_KEY`             | API key for `postsale run` when using Anthropic (auto-detected)                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `ANTHROPIC_AUTH_TOKEN`          | Bearer-token alternative to `ANTHROPIC_API_KEY`; satisfies the Anthropic provider gate                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `OPENAI_API_KEY`                | API key for `postsale run` when using OpenAI (auto-detected)                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `POSTSALE_LLM_PROVIDER`         | Override LLM provider: `anthropic` or `openai`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `POSTSALE_LLM_MODEL`            | Override LLM model (provider-specific)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `POSTSALE_SWARM_RPM_ANTHROPIC`  | Override per-process Anthropic rate-limit cap for swarms (default 30 RPM)                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POSTSALE_SWARM_RPM_OPENAI`     | Override per-process OpenAI rate-limit cap for swarms (default 30 RPM)                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `POSTSALE_SWARM_AUTOAUTH`       | Set to `1` to bypass batch money-moving authorization in non-TTY contexts (use only in pre-authorized environments; emits a warning to stderr for audit)                                                                                                                                                                                                                                                                                                                                                                                  |
| `POSTSALE_CLI_V0_GUARD`         | Set to `off` to bypass the operation safety allowlist. **Trust boundary:** disables a safety gate — testing only, never in shared or agent-driven environments                                                                                                                                                                                                                                                                                                                                                                            |
| `POSTSALE_AUTHORIZED_TOOLS`     | Comma-separated tool names added to the pre-authorized set for `postsale run` (CI use). **Trust boundary:** listed tools skip the confirmation gate                                                                                                                                                                                                                                                                                                                                                                                       |
| `POSTSALE_TEST_LABEL_MODE`      | Any non-empty value (including `0` or `false`) turns on test-label mode: every shipment body the CLI authors is stamped `test_label: true` (disclosed in `applied_defaults`; an explicit `test_label: false`, an empty order-side create, or the `{ order, shipment }` wrapper is refused), and a label purchase reads every shipment first and refuses the whole batch unless each is a test shipment. Constrains only what this process creates and buys; touches no update; weakens no gate; is not authorization. Unset it to disable |
| `POSTSALE_IDEMPOTENCY_KEY`      | Idempotency key for write commands (`--idempotency-key` flag takes precedence; a UUID is generated when neither is set)                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `POSTSALE_TRACE_ID`             | Correlation id injected into error envelopes and outgoing request context for end-to-end tracing                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `POSTSALE_TRACE_DIR`            | Override the tool-call trace directory (default `~/.postsale/traces/`); honored by both trace writing and `query_trace`                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `POSTSALE_TRACE_DISABLED`       | Set to `1` to disable trace writing                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `POSTSALE_TRACE_FULL`           | Set to `1` for full-fidelity traces: raw HTTP request/response bodies AND unredacted tool outputs (by default the credential-bearing outputs — carriers, stores, account — are redacted before recording). **Warning:** persists complete payloads, including credentials, to disk                                                                                                                                                                                                                                                        |
| `POSTSALE_CONVERSATIONS_DIR`    | Override the saved-conversation directory (default `~/.postsale/conversations/`)                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `POSTSALE_SAVED_QUERIES_DIR`    | Override the saved-query directory (default `~/.postsale/saved-queries/`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POSTSALE_UPDATE_MANIFEST_URL`  | Override the `postsale upgrade` manifest URL. **Trust boundary:** decides which release upgrades install; `upgrade` and `--check-update` emit an `update_manifest_override_active` warning while it is set                                                                                                                                                                                                                                                                                                                                |
| `POSTSALE_INSTALL_FAST`         | Set to `1` to select the AVX (non-baseline) x64 build on `postsale upgrade` (and in `install.sh`)                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `POSTSALE_NO_SPINNER`           | Set (any non-empty value) to disable the progress spinner (also disabled when `CI=true` or output is not a TTY)                                                                                                                                                                                                                                                                                                                                                                                                                           |

### Request attribution

Every HTTP request to Postsale services carries
`User-Agent: PostsaleAnywhere/<version> (cli; <os>; <arch>)` — the
PostsaleAnywhere client-family format the platform uses for order-source
attribution (`<os>`/`<arch>` are Node's `process.platform`/`process.arch`;
the version is the build-time stamp shown by `postsale --version`). This is
deliberate platform citizenship: CLI and agent traffic is identifiable and
attributable on the backend. Do not spoof or override it.

## Recommended agent workflow for shipping orders

```bash
# 1. Find what needs to be shipped
postsale orders search --query "awaiting shipment" --page-size 50

# 2. Get rates to pick the cheapest carrier
postsale shipments get-rates --input rates-request.json

# 3. Create the shipment (requires --yes)
postsale orders create-shipment <order_id> --yes

# 4. Purchase the label (requires --yes, charges carrier)
postsale labels purchase --shipment-id <shipment_id> --yes

# Or do all of the above in one step with natural language:
postsale run "ship all awaiting orders with cheapest carrier" --yes
```

## Schema introspection for agents

Before calling a command with complex input, inspect its schema:

```bash
postsale schema shipments.create    # shows request body schema from OpenAPI spec
postsale schema orders.search       # shows search filter schema
postsale schema --list              # all available command slugs
```

## Piping and composition

```bash
# Find orders and process each one
postsale orders search --query "pending today" --all | \
  jq -r '.order_id' | \
  xargs -I {} postsale orders get {}

# Count results
postsale orders search --query "shipped last week" --all | wc -l

# Save structured data
postsale orders search --query "unshipped" --all > unshipped.ndjson
```
