xmemcli 1.5.1__tar.gz → 1.7.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
xmemcli-1.7.6/PKG-INFO ADDED
@@ -0,0 +1,896 @@
1
+ Metadata-Version: 2.5
2
+ Name: xmemcli
3
+ Version: 1.7.6
4
+ Summary: Command-line client for the xmemory HTTP API: PKCE auth, instances, read/write, schema and XMD tools.
5
+ Keywords: api,cli,llm,memory,xmemory
6
+ Requires-Python: <4,>=3.12
7
+ Description-Content-Type: text/markdown
8
+
9
+ # `xmemcli` — xmemory command-line client
10
+
11
+ Single-command CLI for reading from, writing to, and managing xmemory instances over the HTTP API at `$XMEM_API_URL` (default `https://api.xmemory.ai`), using an API key from the Console UI or `xmemcli auth login`.
12
+
13
+ Full command reference is included below.
14
+
15
+ ## Usage
16
+
17
+ ```text
18
+ xmemcli auth login [URL] [--auto-approve] [--no-browser] [--timeout S] [--console-url URL] [--callback-port N] [--email ADDR [--org-name NAME]]
19
+ xmemcli auth logout
20
+ xmemcli auth status
21
+ xmemcli read <query>
22
+ xmemcli write <text>
23
+ xmemcli write (--mutations-file PATH | --mutations-stdin) [--delete TYPE:ID]…
24
+ xmemcli write --delete TYPE:ID [--delete TYPE:ID]…
25
+ xmemcli write-status <write_id> [<id> …]
26
+ xmemcli quota
27
+ xmemcli instance templates
28
+ xmemcli instance create --name NAME (--template ID | --schema-file PATH | --schema-stdin)
29
+ xmemcli binding list [--autoload]
30
+ xmemcli binding add <instance_id> [--tier autoload|available|off] [--scope project|user]
31
+ xmemcli binding remove <instance_id> [--scope project|user]
32
+ xmemcli context [--text] [--max-tokens N] [--no-live-state]
33
+ xmemcli trace ls [--instance ID]… [--trace-type T]… [--hours-back N] [--user-id U] [--api-key-id K] [--limit N] [--cursor TS_MS:TRACE_ID] [--counts-only]
34
+ xmemcli trace ls --search [--text …] [--request …] [--response …] [--object NAME]… [--field NAME]… [--status S]… [--trace-id ID]… [--session-id S] [--instance ID] [--trace-type T|delete]… [--hours-back N] [--include-llm] [--limit N] [--cursor TS_MS:TRACE_ID:INSTANCE_ID]
35
+ xmemcli trace get <trace_id> [--trace-type write|read|create|update|evolution]
36
+ xmemcli trace calls --session-id S [--stage S] [--model M] [--outcome succeeded|failed|refused] [--hours-back N] [--limit N] [--cursor TS_MS:TRACE_ID:INSTANCE_ID]
37
+ xmemcli trace call <llm_call_id>
38
+ ```
39
+
40
+ Global flags / env: `--api-key` / `$XMEM_API_KEY`, `--instance-id` / `$XMEM_INSTANCE_ID`, `--api-url` / `$XMEM_API_URL`, `--rc-dir` / `$XMEM_RC_DIR`, `--binding-dir` / `$XMEM_BINDING_DIR`, `--json`, `--verbose`. See `xmemcli help options`. `trace …` and `auth login` additionally take `--console-url` / `$XMEM_CONSOLE_URL` (the Console origin; inferred for the public, stg, pp and local pairs and the `/console` sibling of a `/xmem` base).
41
+
42
+ Credential resolution: `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` (walk up from `--rc-dir` toward `$HOME`). Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default. The `mcp` transport resolves its own origin the same way: `--mcp-url` → `$XMEM_MCP_URL` → rc `mcp_url` → the API base's `/mcp` sibling or hosted pair (stg, pp, dk) → default, where `mcp_url` is what the API told `auth login` about itself.
43
+
44
+ `context` is the exception: it runs unattended from a session-start hook, with no person and no chosen directory behind it, so it resolves the credential from `$HOME` **only** — a project `.xmemrc.json` is used only when `--api-key` / `$XMEM_API_KEY` or `--rc-dir` / `$XMEM_RC_DIR` names it explicitly. That stops a cloned repository shipping the key and origin its own context is fetched with.
45
+
46
+ Two local files, same discovery rule, different jobs: `.xmemrc.json` holds the credential (`0600`, never committed, nearest wins); `.xmemory.json` holds the instance binding (`0644`, *meant* to be committed at project scope, every scope merged). `binding` commands are local-only — no network, no credential.
47
+
48
+ ## Read / write modes
49
+
50
+ | CLI | API field |
51
+ |-----|-----------|
52
+ | `xmemcli write --extraction fast` (default) | `extraction_logic: "fast"` |
53
+ | `xmemcli write --extraction deep` | `extraction_logic: "deep"` |
54
+ | `xmemcli write --no-wait` | async write; poll with `write-status` |
55
+ | `xmemcli write --mutations-file m.json` / `--mutations-stdin` | `structured_mutations` — the JSON array as given; no `text`, no `extraction_logic` |
56
+ | `xmemcli write --scope Pet:name=Luna --scope-mode drop` | `scope: {objects: [...], mode: "drop"}` — out-of-scope changes are skipped and reported; `reject` is not sent |
57
+ | `xmemcli write --delete Person:name=Ada` | appends `{"object_mutation": {"object_type": "Person", "delete": {"key": {"name": "Ada"}}}}` (`Person:<xuid>` sends `{"xuid": …}` as the key) |
58
+ | `xmemcli read --read-mode single` (default) | `mode: "single-answer"` |
59
+ | `xmemcli read --read-mode raw` | `mode: "raw-tables"` |
60
+ | `xmemcli read --read-mode xresponse` | `mode: "xresponse"` |
61
+ | `xmemcli read --related-types` | `include_related_types: "types"` — the response's `related_types` object is kept in the curated output |
62
+ | `xmemcli read --related-types-depth 2` | `include_related_types: "types"` and `related_types_depth: 2` — the catalog entries carry their `distance` and, below the last level, their own edges |
63
+
64
+ `--read-mode` selects the **server-side answer shape**; `--json` and the TTY rule govern stdout format.
65
+
66
+ ## Terminal colors
67
+
68
+ Interactive help and trace JSON use a dark-terminal palette by default. When `COLORFGBG` reports a white background (`7` or `15`), xmemcli uses darker colors for light terminals. Set `XMEMCLI_THEME=light` or `XMEMCLI_THEME=dark` to choose explicitly when the terminal does not report its background. `NO_COLOR` still disables colors.
69
+
70
+ ## Curated output
71
+
72
+ The CLI does not echo raw API payloads. Server internals (`sql`, `diff_plan`, extraction trees) are never emitted. Read responses keep `pending_suggestions` (schema-evolution hint). A text write keeps a compact `extracted` summary (`Type(identifier)` tags); a structured write runs no extraction and has none. `write` and `write-status` keep the server's `changes` block (created / updated with old → new / deleted, bulk summaries, advisory records) when the write changed something. `--verbose` adds `trace_id` and `console_url` when present.
73
+
74
+ **`trace …` is the one deliberate exception.** Those commands return the Console's own operation documents — the paged operations list, the Inspect search, and the per-operation detail — **verbatim**, because the Console already curates them per caller (a super admin sees LLM calls and raw token counts; everyone else does not). They are JSON on every stream and carry every internal id the Console does: `trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id`, and on detail documents `source_id`, `migration_id`, `llm_call_id`, `gateway_request_id`, …
75
+
76
+ ## Trace — the Console's operation traces, from the CLI
77
+
78
+ ```bash
79
+ xmemcli auth status # super_admin: which Console view trace will answer with
80
+ xmemcli org list instances # the instances this key can see — what trace ls is scoped to
81
+ xmemcli trace ls # every visible instance, newest first, one page
82
+ xmemcli trace ls --instance "$ID" --trace-type write --trace-type read --limit 50
83
+ xmemcli trace ls --counts-only --hours-back 168 # counts, per-instance counts and series, no rows
84
+ xmemcli trace ls --search --text "Ada" --instance "$ID" # Inspect search — opt-in, more expensive
85
+ xmemcli trace get 0001_abc --trace-type write # one operation, every internal id
86
+ xmemcli trace calls --session-id "$SESSION" --stage 'benchmark.*' # a session's LLM calls (super admins)
87
+ xmemcli trace call "$LLM_CALL_ID" # one LLM call whole: prompt, reply, stats
88
+ ```
89
+
90
+ Paging is a cursor the previous page returns. Pipe a page to `jq` with `printf '%s\n'`, never `echo`: under zsh or dash `echo` rewrites backslash escapes inside the JSON. Stop on a non-zero exit: an error is never the last page.
91
+
92
+ ```bash
93
+ PAGE=$(xmemcli trace ls --instance "$ID" --limit 100) || exit
94
+ # `select(.has_more_operations)` so the last page yields an empty string rather than the literal `null:null`,
95
+ # which `--cursor` refuses.
96
+ NEXT=$(printf '%s\n' "$PAGE" | jq -r 'select(.has_more_operations) | "\(.next_before_ts_ms):\(.next_before_trace_id)"')
97
+ [ -n "$NEXT" ] && xmemcli trace ls --instance "$ID" --limit 100 --cursor "$NEXT"
98
+ ```
99
+
100
+ `--search` pages too, with the Inspect endpoint's three cursor fields. Follow `has_more` until it is `false`: a page can come back short, even empty, and still have more. Each page is a full search on the Console, so walk with `--limit 500`, the Console's cap:
101
+
102
+ ```bash
103
+ CURSOR=
104
+ while :; do
105
+ # Any non-zero exit ends the walk without its last page: exit 3 with stage `paging_unsupported`, say, is a
106
+ # Console that ignored the cursor.
107
+ if [ -z "$CURSOR" ]; then
108
+ PAGE=$(xmemcli trace ls --search --text "Ada" --limit 500) || exit
109
+ else
110
+ PAGE=$(xmemcli trace ls --search --text "Ada" --limit 500 --cursor "$CURSOR") || exit
111
+ fi
112
+ printf '%s\n' "$PAGE" | jq -c '.merged[]'
113
+ case "$(printf '%s\n' "$PAGE" | jq -r '.has_more')" in
114
+ true) ;;
115
+ false) break ;;
116
+ *) echo "no has_more in the answer: this Console does not page" >&2; break ;;
117
+ esac
118
+ CURSOR=$(printf '%s\n' "$PAGE" | jq -r '"\(.next_before_sort_at_ms):\(.next_before_trace_id):\(.next_before_instance_id)"')
119
+ done
120
+ ```
121
+
122
+ `trace calls` pages the same way, with the same three cursor fields:
123
+
124
+ ```bash
125
+ PAGE=$(xmemcli trace calls --session-id "$SESSION") || exit
126
+ NEXT=$(printf '%s\n' "$PAGE" | jq -r 'select(.has_more) | "\(.next_before_sort_at_ms):\(.next_before_trace_id):\(.next_before_instance_id)"')
127
+ [ -n "$NEXT" ] && xmemcli trace calls --session-id "$SESSION" --cursor "$NEXT"
128
+ ```
129
+
130
+ `--include-llm` adds model-call hits to a free-text search (`--text`, optionally with `--session-id` or `--hours-back`), for a super admin's key only; any other key gets the operations it gets without the flag and no calls. The CLI refuses the flag with a clause flag or an instance, user or key scope, where the Console's search finds no model call. When a page ends on one, its `llm_call_id` is the cursor's third field, so repeat the flag on every page of that walk.
131
+
132
+ A search with no filter shaping it is the Console's recent-activity view, which reaches back only as far as its window (currently 180 days); when older operations exist, its last page says `truncated` and the CLI warns. A key sees the operations of the instances it can list — never more than its owner sees in the Console; a super admin's key sees what a super admin sees. Full reference: [](#trace--the-consoles-operation-traces).
133
+
134
+ ## Output personality — JSON vs pretty
135
+
136
+ - **Non-TTY stdout** or **`--json`**: exactly one JSON document.
137
+ - **Interactive TTY** without `--json`: short one-line summaries per command.
138
+
139
+ Scripts can rely on `xmemcli … | jq` without `--json`.
140
+
141
+ ## Example session
142
+
143
+ ```bash
144
+ export XMEM_API_KEY="xmem_..."
145
+ export XMEM_INSTANCE_ID="<uuid>"
146
+
147
+ xmemcli write "John likes orange"
148
+ xmemcli read "what colour does John like?"
149
+
150
+ ID1=$(xmemcli write --no-wait "Ivy likes amber" | jq -r .write_id)
151
+ ID2=$(xmemcli write --no-wait "Lev likes indigo" | jq -r .write_id)
152
+ xmemcli write-status "$ID1" "$ID2"
153
+ ```
154
+
155
+ ## Install
156
+
157
+ Requires **Python 3.12+**. Stdlib only — no runtime dependencies.
158
+
159
+ Preferred:
160
+
161
+ ```bash
162
+ uv tool install xmemcli
163
+ ```
164
+
165
+ When uv is not available:
166
+
167
+ ```bash
168
+ pip install xmemcli
169
+ ```
170
+
171
+ Then:
172
+
173
+ ```bash
174
+ xmemcli version
175
+ xmemcli auth login
176
+ ```
177
+
178
+ Version: **1.7.6**.
179
+
180
+
181
+ ---
182
+
183
+ # `xmemcli` auth flow — PKCE handoff, email sign-in, and credentials
184
+
185
+ The CLI is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may even *initiate* key acquisition, but a key is only ever issued after **one explicit human approval** — a click in the browser (PKCE handoff) or an approval of a sign-in email (`--email`). A key must **never enter the AI transcript** — it is handed to the CLI out-of-band via `xmemcli auth login` or environment / `.xmemrc.json`, and the CLI prints only its prefix.
186
+
187
+ Two acquisition paths share `xmemcli auth login`:
188
+
189
+ - **Browser PKCE handoff** (default): the CLI sends the human to **`/xmemcli/<challenge>`** on the Console, listens on an ephemeral **`127.0.0.1`** port named in that URL, and the Console delivers a signed short-lived **code** (not the key) to that callback — in the background where the browser allows it, otherwise through a link the human clicks.
190
+ - **Headless email sign-in** (`--email <addr>`): the CLI plays the waiting-browser role of the Console's cross-device email sign-in and then mints its own key — no browser on this machine. See [Email sign-in](#email-sign-in----email) below.
191
+ - **Zero-click browser handoff** (`--auto-approve`): the same PKCE handoff and the same page, opened with **`?auto_approve=1`**; the Console then shows no controls, obtains a code carrying an *intent* to mint a key named **`CLI auto-approved login (<host>, <timestamp>)`**, and navigates the tab to the loopback callback by itself; the key is created when the CLI redeems that code with its verifier. See [Zero-click handoff](#zero-click-handoff----auto-approve) below.
192
+
193
+ > **Scope.** Shipped in this package: `.xmemrc.json` discovery, PKCE `auth login` / `logout` / `status`, email `auth login --email`, `/whoami` consumption, and `quota`. Server endpoints: `POST /cli/handoff`, `POST /cli/token`, and — for the email path — the Console sign-in and accounts routes listed below (all pre-existing Console surfaces; the email path added no server endpoint).
194
+
195
+ ## Acquiring a key
196
+
197
+ The human either:
198
+
199
+ - **Provides a key they already hold** (`$XMEM_API_KEY`, `.xmemrc.json`, or paste at login), or
200
+ - **Creates a key** in the Console and clicks **"Use this key in the CLI"** (when the page was opened via `/xmemcli/<challenge>`), or
201
+ - **Approves a sign-in email** for an `auth login --email` run, after which the CLI mints its own key.
202
+
203
+ During **onboarding**, the Console may auto-handoff an onboarding key while a short-lived cookie bridge is active.
204
+
205
+ ### Why a code, not the key
206
+
207
+ Plaintext keys in URLs leak to history and proxies. Handoff uses **PKCE + signed code + localhost callback**:
208
+
209
+ 1. CLI mints **`verifier`**, derives **`challenge`** = `base64url(SHA256(verifier))`, opens **`/xmemcli/<challenge>`**.
210
+ 2. Human clicks **"Use this key in the CLI"** → Console **`POST`s the key** to `/cli/handoff` bound to the challenge → receives a short-lived **`code`**.
211
+ 3. Browser delivers to **`http://127.0.0.1:<port>/callback?code=…&state=<challenge>`** — key never in the URL.
212
+ 4. CLI **`POST /cli/token`** with `{ code, verifier }` → receives key → writes `0600` `.xmemrc.json`.
213
+
214
+ ### Sequence
215
+
216
+ ```
217
+ AI-assisted IDE ── xmemcli auth login ──► CLI: PKCE verifier + localhost listener
218
+ Human ── opens Console URL ──► creates key → "Use this key in the CLI"
219
+ CLI ◄── local callback ── redeems code + verifier → writes .xmemrc.json
220
+ AI-assisted IDE ── xmemcli read/write ──► CLI resolves key; never handles plaintext key
221
+ ```
222
+
223
+ ## Zero-click handoff — `--auto-approve`
224
+
225
+ `xmemcli auth login [<url>] --auto-approve` is for a scripted onboarding that must not stop for a click. The logic is the browser handoff's, step for step — verifier and challenge, an ephemeral loopback listener named in the URL, `POST /cli/handoff` for a signed code, the code on the callback, `POST /cli/token` with the verifier, the `0600` rc — with one inversion: **the key is created at the redeem, not before it**. The Console creates nothing; it asks the server for a code that lets the holder of the verifier mint a key, and only the CLI that started the login holds the verifier.
226
+
227
+ 1. The CLI opens **`/xmemcli/<challenge>?callback_url=http://127.0.0.1:<port>/callback&auto_approve=1&host=<hostname>`**. The parameter is what selects the zero-click surface; without it the same URL is the manual page. The callback is always named here, even for the legacy port the manual page would infer: this surface refuses a link without one.
228
+ 2. If the browser holds no Console session, the Console's auth wall sends the human to sign in — email code, Google, GitHub or the deployment's OIDC provider, whichever they use — and returns them to the same URL with its query intact (`?next=`). That return is for an existing account: an address signing in for the first time is sent to the Welcome page to create its account, which drops `next`, so the CLI waits out its timeout and the login must be re-run once onboarded. Nothing about the identity provider reaches the CLI; a session cookie is all the surface needs, and it waits for nothing else.
229
+ 3. With a session, the surface `POST`s `/cli/handoff` an **issue intent**: the challenge and the key name **`CLI auto-approved login (<hostname>, YYYY-MM-DDTHH:MM:SSZ)`** — the email path's `CLI email login (<hostname>, <timestamp>)` shape with the method named — and no key. The hostname travels as `&host=` on the login URL, cleaned by the same rule on both ends (letters, digits, `-._`, at most 32, else `unknown-host`).
230
+ 4. The server, cookie-authed, decides whom the code may mint for: the session's account and organisation, the permission to create a key for oneself (`api_key.create_own` or `create_all`), and the role — the one named `Default`, else the organisation's only role, else a 409 rather than a guess. It signs those ids, the name, the session's email and its authentication time into the 120-second code (`stash_intent`): the same JWT as the manual handoff's, with no `enc_key` because there is no key. **Nothing is created.**
231
+ 5. The surface **navigates** the tab (`location.replace`) to `http://127.0.0.1:<port>/callback?code=…&state=<challenge>`. That is the very request the manual page's "Finish the login in a new tab" link makes on Safari — a top-level navigation, which WebKit's mixed-content rule does not cover and Chrome's Local Network Access permission does not gate — issued by script in the same tab instead of by a click on an anchor. Same-tab navigation needs no user activation (only new windows do), so it is the one delivery that is click-free on every engine, and no fallback link is needed. `replace`, not `assign`, so Back cannot land on a page that starts another handoff.
232
+ 6. The CLI's listener takes the code, checks `state`, and `POST`s `/cli/token` with the verifier. **This is where the key is born** (`_mint_from_intent`): the server checks the challenge against the verifier and creates the key through the same service the API Keys page uses (`create_api_key`), attached to every cluster, as that user, with the session's authentication time so a revocation since the handoff is caught inside the create. What the handoff decided under the session is decided again **inside that service's transaction, with the owner's row locked**, not by the endpoint before it: the account still exists, is active, still carries the signed email (a rename since the handoff moves the revocation cutoff to the old address, which a cutoff check keyed by the current one would never consult), and still holds `api_key.create_own` or `create_all` through its role — read with the role row locked, the lock a permission removal takes (`remove_role_permission`, `update_role`), so the removal is either visible to the mint or waits for the key to exist. A check made before that lock could be overtaken by either change committing between it and the insert. A super admin, read under the same lock, needs no permission row and binds any role, as the API Keys endpoint grants one. **An intent mints exactly once**: each carries an id, the key it mints records that id in `api_keys.handoff_intent_id` under a unique partial index, and a second redeem — concurrent, or after the first key was deactivated or renamed — fails on that index inside its own insert. Rows in `api_keys` are never hard-deleted, so a consumed id stays consumed. Only that index's violation is a redeem; any other integrity failure of the insert (a role deleted underneath it) is an error, not a claim that a key exists. That second redeem is answered **409**, naming the key and its prefix (names carry no uniqueness; same-second logins from one host are named alike): the caller proved the verifier, and what it needs is the key's name to find it under API → API Keys and deactivate it if it is still active, since a stored key's plaintext cannot be served again. The CLI prints that message verbatim and stops, instead of calling the code expired. A redeem whose reply is lost after the request was sent (a read timeout or a reset, which urllib raises bare rather than as `URLError`) is sent once more with the same code to the same URL, which yields the key if the first never arrived or that 409 if it did; lost twice, the CLI stops and says a key may exist to deactivate, rather than letting the callback worker die and the login wait out its timeout. The login's deadline bounds the wait for the callback, not the redeem: a callback captured in time — the listener's handler publishes the capture the moment it stores it, so the last instant before the deadline counts — is redeemed within the redeem's own bound (two sends at the redeem timeout, plus a second) even past the deadline, since the redeem is what makes the key exist, and a redeem that outlives that bound ends the login with the same ambiguity stated, as exit 2 rather than the timeout's 5. A callback that arrives after Ctrl+C, or after a pasted key won, is not redeemed at all; a pasted key is not used while a redeem is in flight, since that redeem's key is the one that will exist; Ctrl+C during a redeem says a key may exist, with its prefix when the reply landed in time. None of this touches the login without the flag. A super admin, whose session names no organisation, is issued the key in their own. The CLI writes the rc exactly as the manual path does. Its JSON output carries `"auto_approve": true` beside `"method": "pkce"`.
233
+
234
+ The surface shows only where it is or why it stopped; every failure it can show ends with "No key was issued.", which is true by construction. The one retry a person has is to re-run the login command they started, with its own flags (a positional URL, `--api-url` and `--console-url`, a pinned `--callback-port`) and `--auto-approve`; the copyable line on that surface is the command's simplest form, not a substitute for those flags. The flag cannot combine with `--email` (exit 2): that path has no page to approve on. The CLI's done page, which the tab lands on, says "Code received" and names the terminal for the result, because it is served before the redeem; it carries `Referrer-Policy: no-referrer` as a header and a meta tag, since its URL holds the signed code and the brand stylesheet and fonts it loads come from other hosts.
235
+
236
+ **Why this needs no probe, and what remains.** The manual page probes the listener (`/permission-probe` with the `X-Xmemcli` marker) before it mints in the background, because there a key already exists when the code is handed out. This surface cannot probe — a probe from a page load has no user activation, and Chrome denies it without asking — and does not need to: an authored `?auto_approve=1` link opened by a signed-in person delivers a code to a port on **their own** loopback, and unless something there redeems it with the matching verifier, nothing is persisted. A closed port, a browser error page, a link opened twice: none of them leaves a key behind. What the code exposes if captured is ids, a key name, an email and a timestamp, for 120 seconds, redeemable only with the verifier that matches its challenge.
237
+
238
+ The residual is the authored link itself: there the verifier belongs to whoever wrote the link, so anything able to read that loopback port on the victim's machine — another account's process on a shared box, a port forwarded in with `ssh -L` — could relay the code to the author, who redeems it for a key on the victim's account. This is **accepted**: a process on the victim's loopback already implies code execution as them, the same line the manual page's probe draws (`cli_handoff.js`), and the key such a relay yields is named for its origin and revocable on the API Keys page. The honest guard against it would be one click, which is what the flag exists to remove.
239
+
240
+ ## Email sign-in — `--email`
241
+
242
+ `xmemcli auth login --email <addr> [--org-name <name>]` acquires the key with **one human action and no browser on this machine** — the flow an agent-driven IDE uses. The CLI takes the waiting-device role of the Console's cross-device email sign-in; every endpoint below already served the Console before this flag existed. **The fully headless path requires the server to have cross-device hand-off enabled**; a server without it only offers the typed six-digit code, so a run with no interactive terminal is refused there (after cancelling the attempt it had to start to find out).
243
+
244
+ 1. `POST /api/kingdom-signin-request` — the server emails a six-digit code + one-click link; the CLI holds the signed pending cookie **in memory**. The reply's **matching code** is consumed only as the hand-off signal and is not printed (the compare UI is retired across all sign-in surfaces for now).
245
+ 2. The human opens the email on any device and Approves. *This is the single human action.*
246
+ 3. The CLI claim-polls `POST /api/kingdom-signin-claim` on the Console's own waiting-tab cadence (a short grace, ten fast polls, then a slow poll; transient network failures count as missed polls, not aborts) and exchanges the proof via `POST /api/kingdom-signin-mint` for the browser-session cookie — held **in memory only, never written to disk**.
247
+ 4. With the session, the CLI runs the Console onboarding's core sequence: `GET /api/check-user`; when the address has no account, `GET /api/suggest-slug` (server-derived org name, `--org-name` overrides; ignored for an existing account) and `POST /api/public-signup` — **note that creating a new organisation requires the deployment to allow public registration, which the hosted service currently does not**: an existing account gets a key, a new address gets the server's "organisation creation is not available" refusal (the Console's admin-fallback signup arm is not implemented here); then `GET /api/accounts/users/by-email`, `GET /api/accounts/api-roles` (the seeded default role, or the org's only role — an ambiguous set is refused, never guessed), and `POST /api/accounts/api-keys` with **no cluster list** — the key attaches to every cluster the org has at mint time. Clusters created later are attached by a server-side backfill that repairs only keys already linked to every other cluster in the org; an org with no clusters at mint time gets a key attached to nothing. The key is named **`CLI email login (<hostname>, <timestamp>)`** so every mint stays individually identifiable and revocable on the Console's API Keys page.
248
+ 5. The key is written to `.xmemrc.json` (0600), stdout carries the prefix only, and the session cookie dies with the process.
249
+
250
+ The wait is bounded by `--timeout` — 600 seconds for this path by default, which is also a **ceiling**: nothing stays claimable past the server's pending window, so larger values are clamped. The emailed one-click link and six-digit code themselves expire at about five minutes; the wait continues past that because an approval given in time stays claimable, and the terminal says when the emailed artifacts have lapsed. The Console origin must be `https` (or a loopback host, for local development). The Console must also match the API origin the key will be used against: with a non-default `--api-url` the CLI only infers a Console where the layout determines one — a single-origin `/xmem` base names its `/console` sibling on the same host, and that sibling beats `$XMEM_CONSOLE_URL` on the same call — a hosted deployment this client knows (`api.stg` / `api.pp.xmemory.ai`) names its `console.` host, and the local-dev convention pairs `:8000` with `:8002` — otherwise it refuses to guess (pass `--console-url`; `$XMEM_CONSOLE_URL` only when `--api-url` was not given). Because that inference exists, a single-origin deployment's rendered sign-in command names the API alone; every other layout still renders the `--api-url`/`--console-url` pair, since naming one without the other is worse than naming neither (`api/agent_setup_renderer.py`), and an explicitly named Console that provably mismatches the API origin (mixed loopback-ness, a default-production half paired with a non-default one, or a stg / pp half paired with anything but its own origin; a single-origin `/console` beside its own `/xmem` on one origin is one deployment, even on a stg or pp host, while the production halves are still held to their defaults) is refused rather than trusted.
251
+
252
+ When the server offers no matching code on the request reply (cross-device hand-off disabled, no public Console origin configured, or a transient degradation), there is nothing for a headless starter to claim — the emailed link then signs in the *approving* browser — so the CLI falls back to the emailed **six-digit code typed into the terminal** (`POST /api/kingdom-signin-verify`), still a single human action. That fallback needs an interactive stdin; a non-interactive run cancels the attempt it just started (the mode is only known from the server's reply, after the email is on its way) and refuses with instructions — the typed code dies with that cancel, but on such a server the one-click link has nothing durable to revoke and stays redeemable in a browser until it expires (about five minutes), which the terminal states plainly.
253
+
254
+ A credential that already resolves (via `/whoami`) to the requested address at the requested origin — checked across `--api-key`, `$XMEM_API_KEY`, and the rc, in that order — short-circuits without sending any email, so re-running the command is free (a matching `$XMEM_API_KEY` is copied into `.xmemrc.json` so the project gains a durable credential; an explicit `--api-key` is never persisted); when the check itself cannot be made (the API is unreachable) the CLI refuses to mint rather than stranding a duplicate key. Every abort of a started attempt — Ctrl+C, timeout, or an error mid-wait — best-effort-cancels it (`POST /api/kingdom-signin-cancel`, retried briefly) and reports what the cancel actually achieved: a **confirmed** cancel of a cross-device attempt makes the emailed link unapprovable; an unconfirmed one is reported as such; and on a hand-off-disabled server the link has nothing durable to revoke — it stays redeemable (in the browser that opens it) until it expires on its own, and the terminal says so.
255
+
256
+ ## Credential store (`.xmemrc.json`)
257
+
258
+ Project-local **`.xmemrc.json`**: `{email, api_url, api_key}`. Found by walking up from `--rc-dir` / CWD, ceiling `$HOME`.
259
+
260
+ Those three keys are required and must be strings. Anything else in the file is **ignored, not rejected** (since 0.0.12): the rc is the only durable credential store, so a file this CLI cannot load is a CLI that cannot run any command at all — which would make adding a field to the file a breaking change far wider than the feature that added it.
261
+
262
+ Since 1.1.0 the file may also carry **`mcp_url`** — the MCP resource the API reported for itself when the key was issued, written only when the server names one. It is what lets `xmemcli mcp` reach an on-premise MCP host: that host is in no table the client was built with, and the rc is the one anchor a cloned `.mcp.json` does not control. A blank or non-string value is read as absent. It outranks the single-origin sibling inference below — a deployment naming itself beats a shape we derived — and both beat the built-in default. The mount path is part of the destination: `https://host/mcp` authorises that mount, not the sibling services beside it — and the instance id is canonicalised as a UUID before it is appended, so nothing following the mount can walk back out of it. Every lookup that reads `mcp_url` refuses redirects rather than merely dropping the credential across them, because what it learns is a host the key is later sent to. Every mutation of the file — write, in-place update, and logout's delete — is serialised under an exclusive lock on its containing directory, and an update is additionally bound to the identity *and* origin it was decided from. What protects an in-place update is that comparison, which is portable; the lock narrows what remains — the moment between comparing and replacing — where the platform offers one. A failed record is a warning, a failed de-authorisation fails the command. The enrichment write after login is conditional on the same comparison, so logging out or signing in again while it is in flight is not undone by it.
263
+
264
+ Resolution order: `--api-key` → `$XMEM_API_KEY` → `.xmemrc.json`. Origin: `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **except** when the key is explicit (`--api-key` / `$XMEM_API_KEY`): the rc's `api_url` is skipped and the origin falls back to the built-in default unless you also set `--api-url` / `$XMEM_API_URL`. The localhost-key-at-prod guard applies when the key comes from the rc and an explicit `--api-url` / `$XMEM_API_URL` mismatches that rc origin. A base URL's path is preserved, never stripped — on a single-origin deployment the API lives at `https://host/xmem`, and that mount is part of every request the CLI builds; query, fragment, and userinfo on a base URL refuse normalisation rather than being stripped, so sibling inference cannot run on a shape the caller did not supply. Console resolution for login: `--console-url` → Console pinned by a positional (or mistaken `--api-url`) single-origin URL → `$XMEM_CONSOLE_URL` when `--api-url` was *not* given → sibling or known-deployment inference from the call's API base (`--api-url` → `$XMEM_API_URL` → rc `api_url`) when it applies → the production Console only for the exact production API base; anything else is refused. The rc is read for the API origin only when no API is named on the call, and a file that cannot be read or decoded names no origin. An explicit `--api-url` suppresses `$XMEM_CONSOLE_URL` for that invocation (a five-host `--api-url` without `--console-url` is a usage error, not a fall-through to the env Console). A named API origin from `$XMEM_API_URL` or the rc is held to the same infer-or-refuse rule (a refusal for an rc origin names the file and how to sign in to production instead), so an env-only non-default API without a Console cannot mint at the hosted Console and record that key against the foreign API; an inferred sibling that is remote HTTP is refused under the same HTTPS-or-loopback rule as a positional login URL. An explicitly named Console (flag, env, or pinned) is also cross-checked against the API origin on this path — the browser redeem goes to that same Console, so a wrong one would mint the key, and the interactive paste fallback presents the pasted key to it as a bearer — and a provably mismatched pair (mixed loopback-ness, a production half paired with a non-production one, or a stg / pp half paired with anything but its own origin; a single-origin `/console` beside its own `/xmem` on one origin is one deployment, even on a stg or pp host, while the production halves are still held to their defaults) is refused before either worker starts, in the same words as the email sign-in. When the API comes from the rc: an origin that is neither https nor loopback (`localhost`, `127.0.0.1`, `[::1]`) is refused (naming a bare origin with `--api-url` beside an https `--console-url` accepts it), and if that API names its own Console (a `/xmem` base its `/console`, a local `:8000` its `:8002`, stg / pp their table Console), a named Console must be that one, in any spelling of that origin (case, a default port, trailing dots on a named host, `localhost` / `127.0.0.1` / `[::1]` for a loopback one) but on the same mount — any other is refused (exit **2**), so a `$XMEM_CONSOLE_URL` exported for one deployment cannot mint a key into the rc of a deployment whose Console is known. Naming the API on the call makes the pair the caller's: `--api-url` with the Console in `--console-url`, or `$XMEM_API_URL` with it in `--console-url` or `$XMEM_CONSOLE_URL`. `auth status`, `trace` and the `org` permission probe hold a named Console to the same rule while the call names no API: the first two exit **2**, saying how to keep that Console, and the probe declines to send the key and reports the original 403. There, naming the API on the call (`--api-url` or `$XMEM_API_URL`, which must match the rc's origin the key is bound to) lifts the rule, and the Console still comes from `--console-url` (`trace`) or `$XMEM_CONSOLE_URL` — unlike login, `--api-url` does not turn `$XMEM_CONSOLE_URL` off for these. The same resolved Console is passed to the post-login `/whoami` that records `mcp_url`, so a leftover `$XMEM_CONSOLE_URL` cannot steer that lookup either. When `--api-url` is present, `$XMEM_API_URL` is not consulted for Console inference. Because a mounted base cannot also serve the `/api`-prefixed spelling of a route, the compatibility candidate in `whoami_urls` / `token_redeem_urls` (`{base}/api/whoami`, `{base}/api/cli/token` — a root-served deployment's second form) is offered only for a path-less base. The MCP bridge resolves its endpoint as `--mcp-url` → `$XMEM_MCP_URL` → the rc's `mcp_url` → the `/mcp` sibling or hosted pair (stg, pp, dk; `_login.HOSTED_DEPLOYMENTS`) of the effective API base (`--api-url` → `$XMEM_API_URL` → rc → default) → default, and its foreign-origin guard accepts the `/xmem` ↔ `/mcp` sibling pair as one deployment.
265
+
266
+ ## Identity — `/whoami`
267
+
268
+ `xmemcli auth status` calls **`GET /whoami`** with the active bearer key and reports `{authenticated, email, key_prefix}`. Exit `0` when the key resolves; auth error when it does not.
269
+
270
+ The `AuthApiKey` body also carries **`mcp_url`**, this deployment's own MCP origin (`null` where it serves none). The field is always present, `null` included: an absent key means the server predates it, which is a different answer and is treated as one. `auth login` reads it from there — on both paths, since the email flow never calls `POST /cli/token` — and records it in the rc. The lookup is best-effort: any failure simply leaves the key out of the file, and the transport falls back to its table of shipped API↔MCP pairs.
271
+
272
+ ## Security model
273
+
274
+ | Threat | Mitigation |
275
+ | --- | --- |
276
+ | Key in AI transcript | localhost callback / in-process mint → `0600` store; login stdout shows `key_prefix` only |
277
+ | Key in URL | URL carries code only; key over TLS on handoff/token |
278
+ | Captured code replay | PKCE verifier never leaves CLI; code is TTL-bounded |
279
+ | CSRF on callback | state = PKCE challenge; redeem requires verifier |
280
+ | Wrong Console page | CLI button only on `/xmemcli/<challenge>` |
281
+ | Authored `/xmemcli/…?auto_approve=1` link opened while signed in | the zero-click surface creates no key: it obtains a code carrying an *intent* and navigates to the person's **own** loopback; the key is minted only when `/cli/token` is redeemed with the matching verifier, and an intent mints exactly once (unique index on `api_keys.handoff_intent_id`). A closed port or a repeated link persists nothing. **Accepted residual:** the link's author holds that verifier, so a process able to read the victim's loopback port could relay the code to them — the same local-code-execution line the manual page's probe draws; the resulting key is named for its origin and revocable |
282
+ | Session token outliving the login | the email-path session cookie is held in process memory only — never printed, never on disk — and is discarded when the process exits; the token itself stays server-valid until its ~2-day expiry, so the containment is the CLI never letting it out of memory, not revocation |
283
+ | Session cookie over cleartext | the email path refuses non-`https` Console origins (loopback excepted), refuses every redirect on its cookie-carrying requests, and fails fast when a tunnelled Console marks its cookie `Secure` for an `http://` origin |
284
+ | Unsolicited sign-in email initiation | server-side send limits per mailbox (enforced per server node, so the fleet-wide budget scales with node count); the email itself grants nothing without the inbox owner's Approve, and the CLI best-effort-cancels attempts it cannot complete |
285
+ | Approving someone else's email sign-in | **user-mediated** — the confirmation page leads with "only if this is your address" and states that approving lets the requesting device act on the account, including creating API keys; the stake is higher here than in the browser flow, because approving a CLI-started attempt ends in a durable API key, not a two-day browser session. The matching-code comparison that once narrowed this further is retired for now (the code still travels on the wire but no surface displays it). Approve only sign-ins you started. |
286
+
287
+ Each login binds its own port and names it in the login URL, so concurrent logins never contend for one listener. Security rests on PKCE and on the callback being reachable only from the CLI's own machine, not on port secrecy. For SSH or devcontainer use the port has to exist before the tunnel does, so pin it: `--callback-port <n>` or `$XMEM_CALLBACK_PORT`, then forward that port.
288
+
289
+ ## Quota (observe vs escalate)
290
+
291
+ **`xmemcli quota`** observes usage freely. **Raising** limits requires human approval in the Console — the CLI must not self-grant quota increases.
292
+
293
+ ---
294
+
295
+ # `xmemcli` — capabilities reference
296
+
297
+ **Version:** `1.7.6` (see `xmemcli version`)
298
+
299
+ `xmemcli` is the command-line client for the **xmemory HTTP API**. It is **human-approved for key acquisition**: an AI-assisted IDE may run reads, writes, and inventory, and may initiate `auth login`, but a key is only issued after one explicit human approval — a click in the Console UI (PKCE handoff), an approval of a sign-in email (`--email`), or, with `--auto-approve`, the Console session the human already holds in that browser (signed in before, or right then through the Console's own email, Google, GitHub or OIDC sign-in). Quota limits change only in the **Console UI**. The full acquisition contract is in the auth-flow reference bundled with this package.
300
+
301
+ ```bash
302
+ xmemcli … # installed CLI
303
+ ```
304
+
305
+ ---
306
+
307
+ ## Commands
308
+
309
+ Every command below has dedicated help — see [Help](#help).
310
+
311
+ ### Data
312
+
313
+ | Command | Description |
314
+ |---------|-------------|
315
+ | **`write`** `[text]` | Sync write (`POST …/write`). `--extraction fast\|deep` (default `fast`). `--no-wait` queues async write, returns `write_id`. `--scope TYPE:ID` (repeatable) anchors the write to existing objects: only they may be modified/deleted; by default new objects and links to them are allowed and anything else fails, and under `--scope-mode drop` only the objects a `--scope` names may be created and everything else is skipped; works with either extraction mode. `--scope-mode drop` (needs `--scope`) skips out-of-scope changes instead of failing and lists them under `changes.skipped_out_of_scope` (a server change, not a CLI one: past 100 entries the server folds the rest into lines shown with `folded=true` and no key or fields); it creates only records a `--scope` names by primary key, and such a record need not be stored yet — create-or-update exactly the named record. `reject` is the server default and is not sent. Instead of text, a **structured write** sends `structured_mutations` — deterministic creates, updates and deletes with no extraction: `--mutations-file PATH` or `--mutations-stdin` (one JSON array, same shape as the API's `structured_mutations`; see [Structured writes](#structured-writes)) and/or `--delete TYPE:ID` (repeatable, `--scope` syntax: `Person:<xuid>` or `Person:name=Ada`; appended after the file's entries). Text and mutations are exclusive, as are the file and stdin forms; mutations refuse `--scope` and `--extraction deep`; empty or whitespace-only text is refused (exit **2**, before anything is read or sent). A text write's output lists what it extracted as `Type(identifier)` tags (`extracted`); a structured write's does not. Both carry the server's `changes` block (`created` / `updated` with per-field `old_value` → `new_value` / `deleted`, and any other bucket such as `created_keyless_objects`) whenever the write changed something, and on a drop-mode write that skipped everything, where the block carries `skipped_out_of_scope` alone; a scoped write that left a scoped record untouched carries it too, with `untouched_scope_records`. A `--no-wait` answer carries only the `write_id`; `write-status` reports the `changes` once the write completes. |
316
+ | **`write-status`** `<write_id>` … | Poll async writes until terminal or `--timeout` (default 60s). Accepts multiple ids. A completed write's non-empty `changes` block is kept, as on `write`. |
317
+ | **`read`** `<query>` | Read from active instance. `--read-mode single\|raw\|xresponse`, `--scope TYPE:ID` (repeatable), `--scope-relations`, `--related-types` (also return `related_types`: per object type the read touched, the fields it did not return and the object types a declared relation links it to; needs `instance.get_own` on the key), `--related-types-depth N` (1, 2 or 3, refused as a usage error otherwise; follow the relations N levels out, each catalog entry saying its distance; implies `--related-types`). |
318
+
319
+ Requires **`--instance-id`** or **`$XMEM_INSTANCE_ID`** for read/write (or auto-pick when exactly one instance is visible — see [Global flags](#global-flags--environment)).
320
+
321
+ #### Structured writes
322
+
323
+ `--mutations-file PATH` / `--mutations-stdin` send one JSON array as the API's `structured_mutations`, as given. The server takes at most **100** entries per write, `--delete` entries included; the CLI refuses more with exit **2** before sending anything. `--mutations-stdin` needs a pipe or a file on stdin and refuses a terminal. Each entry is one of:
324
+
325
+ - **`object_mutation`** — `{"object_mutation": {"object_type": "Person", "create": {"key": {"name": "Ada"}, "values": {"age": 36}}}}`. `update` takes the same `key` + `values` (the key may be `{"xuid": "…"}`; a null value clears a field); `delete` takes `{"key": {…}}` alone.
326
+ - **`relation_mutation`** — `{"relation_mutation": {"relation_type": "works_at", "create": {"endpoints": [{"object_name": "person", "key": {"name": "Ada"}}, {"object_name": "company", "key": {"name": "ACME"}}]}}}`. `create` lists every participant role, each keyed by primary key or `{"xuid": "…"}`, with no `key` and no `values`. `delete` takes either `endpoints` (a subset may be given) or a top-level `"key": {"xuid": "…"}` — never both — and `"allow_bulk_delete": true` to remove every matched link (without it, a delete matching more than one link is refused). Relation `update` is always refused: re-link by deleting and creating.
327
+
328
+ `--delete TYPE:ID` appends the object `delete` above; each key field may appear once and none may be empty. A delete that matches no record is not an error: exit **0** and no `deleted` line. The server lists it under `changes.unmatched_deletes`, which a terminal prints as `note: unmatched_deletes Task task_id='T-999'`; a server that predates that bucket reports nothing, and the write then reads `write <id> ok · no changes reported`.
329
+
330
+ Every current server accepts structured writes; an older one that had them switched off (`execution.structured_writes_enabled`, since removed) answers **400** `Structured writes are not enabled on this deployment.`; a server that predates structured writes answers **422** with two errors, both reported: `Field required (field: text); Extra inputs are not permitted (field: structured_mutations)`. Both exit **3** with `stage: "request"`.
331
+
332
+ ---
333
+
334
+ ### Org inventory
335
+
336
+ Permission-filtered view of what this API key can see.
337
+
338
+ | Command | Description |
339
+ |---------|-------------|
340
+ | **`org list clusters`** | Clusters visible to this credential |
341
+ | **`org list instances`** | Instances visible to this credential, as a bare array of records. A key minted with `data.read` but not `instance.get_own` (the supported shape for `trace …`) gets the ids only — the same set `trace ls` is scoped to — as a **degraded document** rather than an array: `{"items": [{"id": …}, …], "degraded": true, "missing_permission": "instance.get_own"}`, with the warning on stderr and in the document's `warnings`. The shape changes so a script cannot read an ids-only listing as a complete one; an array is never wrapped just to carry a warning. |
342
+ | **`org list keys`** | API keys visible to this credential (metadata only; plaintext never returned) |
343
+
344
+ ---
345
+
346
+ ### Instances
347
+
348
+ | Command | Description |
349
+ |---------|-------------|
350
+ | **`instance get`** `<instance_id>` | Fetch one instance |
351
+ | **`instance templates`** | List the ready-made templates a create can name — id, label, one-line description, who each is for, example writes and reads, and the object-type names. No schema crosses the wire; the server holds the shape. |
352
+ | **`instance create`** | Create from a template or an XMD/JSON schema. Required `--name`; then exactly one of `--template <id>`, `--schema-file` or `--schema-stdin`. `--template` sends only the id: no schema is composed, generated or repaired, so the shape you get is exactly the one you picked. |
353
+ | **`instance instructions`** `[instance_id]` `[text]` `[--clear]` | Read, set or clear the instance's standing instructions to an agent — the owner's own words, carried to every agent that connects it. No text and no `--clear` reads and changes nothing. Setting reads the current value first and writes with the epoch it read, so an edit composed against instructions someone has since replaced is refused rather than applied (the console form and the instance chat write the same field). Clearing is `--clear`; an empty string is refused, because that is what a shell substitutes for an unset variable. The id may be pinned with `--instance-id` or `$XMEM_INSTANCE_ID` and the text given alone — a lone positional that is not an id is read as the text. |
354
+ | **`instance setup`** `[instance_id]` `[--format agent\|project]` | How to connect this instance to an agent, ordered for where it is likely to be used. Prints the same steps the `get_setup_instructions` MCP tool gives an agent, so a person and the agent beside them read the same thing. `--format project` additionally prints the shared setup files to commit; each teammate still approves the install and signs in once. Carries no credential. |
355
+
356
+ ---
357
+
358
+ ### Schema & XMD
359
+
360
+ XMD (xmemory Data) is the typed schema format for instances. These commands mirror the Console schema workflow (read/update/dry-run/migrations, LLM generate/enhance, and validation).
361
+
362
+ #### Command map
363
+
364
+ | User task | CLI command | Notes |
365
+ |----------|-------------|-------|
366
+ | Read live schema | **`schema get`** `[instance_id]` | Live schema for one instance. `-o` writes YAML. |
367
+ | Apply migration | **`schema update`** `[instance_id]` | `--schema-file`, optional `--migration-plan`, `--confirm-destructive`. |
368
+ | Preview migration | **`schema dry-run`** `[instance_id]` | Same flags as `schema update`; returns planned DDL without applying. |
369
+ | List migrations | **`schema migrations list`** `[instance_id]` | Newest first. `--limit`, `--before-id`, `--include-yaml`. |
370
+ | Fetch one migration | **`schema migrations get`** `[instance_id]` `<migration_id>` | Optional positional instance id; else `--instance-id` / `$XMEM_INSTANCE_ID`. `--include-yaml`. |
371
+ | Review suggestions | **`schema suggestions review`** `[instance_id]` | Suggestion engine: consolidated proposal from read-traffic gaps. |
372
+ | Decide suggestions | **`schema suggestions decide`** `[instance_id]` | `--proposal-version` (required) + `--accept`/`--reject`/`--defer`/`--file`. |
373
+ | Apply suggestions | **`schema suggestions apply`** `[instance_id]` | `--proposal-version` plus mandatory `--confirm-destructive`; preview removals in Console first. |
374
+ | Enhance bound schema | **`xmd enhance --from-instance`** `<description>` | Uses live schema; requires instance id. |
375
+ | Synthesize schema | **`xmd generate`** `<description>` | Cluster-scoped LLM synthesis. `-o` writes YAML. |
376
+ | Enhance from file | **`xmd enhance`** `[schema_file]` `<description>` | From a file, or `--from-instance` for bound enhance. `-o`, `--plan-output`. |
377
+ | Create instance | **`instance create`** | `--name`, `--schema-file` or `--schema-stdin`. |
378
+ | Validate schema | **`xmd validate`** / **`validate-yaml`** / **`validate-json`** | Cluster-scoped server validation. |
379
+ | Convert JSON Schema | **`xmd convert`** | JSON Schema → XMD YAML via the API (`validate_json_schema`). |
380
+
381
+ **Not exposed in CLI** (Console-only today): the suggestions before/after YAML **preview**.
382
+
383
+ #### Typical workflow
384
+
385
+ ```text
386
+ # 0. Or skip 1-2 entirely: pick a ready-made shape
387
+ xmemcli instance templates
388
+ xmemcli instance create --name demo --template personal_crm
389
+
390
+ # 1. Synthesize or enhance
391
+ xmemcli xmd generate "people and their pets" -o schema.yml
392
+ xmemcli xmd enhance schema.yml "add a Pet.breed field" -o next.yml --plan-output plan.json
393
+
394
+ # 2. Validate before create or update
395
+ xmemcli xmd validate next.yml
396
+
397
+ # 3. Create instance OR migrate existing
398
+ xmemcli instance create --name demo --schema-file schema.yml
399
+ xmemcli schema dry-run INST_ID --schema-file next.yml --migration-plan plan.json
400
+ xmemcli schema update INST_ID --schema-file next.yml --migration-plan plan.json --confirm-destructive
401
+
402
+ # 4. Inspect history
403
+ xmemcli schema migrations list INST_ID
404
+ xmemcli schema migrations get INST_ID MIGRATION_UUID
405
+
406
+ # 5. Suggestion engine (from real read traffic) — confirm with the user before decide/apply
407
+ xmemcli schema suggestions review INST_ID
408
+ xmemcli schema suggestions decide INST_ID --proposal-version PV --accept FP1 --accept FP2
409
+ xmemcli schema suggestions apply INST_ID --proposal-version NEXT_PV --confirm-destructive
410
+ ```
411
+
412
+ #### Commands
413
+
414
+ | Command | Description |
415
+ |---------|-------------|
416
+ | **`schema get`** `[instance_id]` | Live XMD schema. Optional `-o` file. |
417
+ | **`schema update`** `[instance_id]` | Replace schema from `--schema-file`. Optional `--schema-type`, `--migration-plan`, `--confirm-destructive`. |
418
+ | **`schema dry-run`** `[instance_id]` | Preview migration (same schema/plan flags as update). |
419
+ | **`schema migrations list`** `[instance_id]` | Applied migrations, newest first. `--limit` (default 50), `--before-id`, `--include-yaml`. |
420
+ | **`schema migrations get`** `[instance_id]` `<migration_id>` | One record. Optional positional instance id; else global instance flags. `--include-yaml`. |
421
+ | **`schema suggestions review`** `[instance_id]` | Consolidated proposal from read-traffic gaps. Returns `proposal_version` + items. |
422
+ | **`schema suggestions decide`** `[instance_id]` | Record accept/reject/defer. Requires `--proposal-version` plus fingerprints or `--file`. |
423
+ | **`schema suggestions apply`** `[instance_id]` | Apply accepted items as one migration. Requires `--proposal-version` and `--confirm-destructive`. |
424
+ | **`xmd generate`** `<description>` | LLM-synthesize XMD. Optional `-o`. Requires cluster resolution. |
425
+ | **`xmd enhance`** `[schema_file]` `<description>` | LLM-augment schema from file, or **`--from-instance`** for live schema. `-o`, `--plan-output`. |
426
+ | **`xmd validate`** `<schema_file>` | Validate; YAML vs JSON inferred from `.json` extension. |
427
+ | **`xmd validate-yaml`** `<schema_file>` | Force YAML validation endpoint. |
428
+ | **`xmd validate-json`** `<schema_file>` | Force JSON Schema validation endpoint. |
429
+ | **`xmd convert`** `<json_schema_file>` | JSON Schema → XMD YAML via the API (`validate_json_schema`). Optional `-o`, `--root-object`. |
430
+
431
+ Instance id for `schema *` commands: positional `[instance_id]`, else **`--instance-id`** / **`$XMEM_INSTANCE_ID`**.
432
+
433
+ ---
434
+
435
+ ### Binding (local only — no network, no credential)
436
+
437
+ Which instances an agent working in **this directory** should know about, and how eagerly to
438
+ engage each one. Stored in `.xmemory.json`, discovered by walking **up** from `--binding-dir` /
439
+ `$XMEM_BINDING_DIR` (or the cwd), ceilinged at `$HOME` — the same rule `.xmemrc.json` has always
440
+ used, and **no git anywhere**. From a start outside `$HOME` the walk checks that directory and
441
+ stops, rather than climbing into a shared parent like `/work` or `/tmp` where another user's file
442
+ could be planted. A session-start hook passes the project root explicitly, so it never depends on
443
+ the walk at all.
444
+ `~/.xmemory.json` is consulted as the outermost scope whenever `$HOME` is the user's own rather than part of the tree being read. Where the session root *is* the home directory — a dotfiles repository, a devcontainer or an archive unpacked as `$HOME` — that file is treated as project scope like any other file inside the tree.
445
+
446
+ Unlike the rc — where the nearest file simply wins — every scope is **merged**, git-config style:
447
+ `~/.xmemory.json` supplies personal instances, a repository's committed `.xmemory.json` adds the
448
+ team's, and the nearer file wins **field by field** on any instance both mention.
449
+
450
+ | Command | Description |
451
+ |---------|-------------|
452
+ | **`binding list`** | Merged view for this directory. `--autoload` lists only what a session-start hook would pull. |
453
+ | **`binding add <id>`** | Bind an instance, or update the binding it already has. `--name`, `--purpose`, `--tier`, `--engage` (repeatable), `--scope`. |
454
+ | **`binding remove <id>`** | Unbind from **one** scope. To silence an instance inherited from a wider scope, bind it here with `--tier off` instead. |
455
+ | **`context`** | Session-start pack for the `autoload`-tiered bindings here. `--text` prints the pack alone, `--max-tokens`, `--no-live-state`, `--timeout`. The only one of these that needs a credential. |
456
+
457
+ **Tiers:** `autoload` (pull its context every session) · `available` (default; engage on demand) ·
458
+ `off` (bound but dormant).
459
+
460
+ **Scopes:** `--scope project` (default) writes the committable file — at an existing nearer
461
+ binding, else the git work-tree root **when git can say and the reader would actually look
462
+ there**, else the cwd. git only ever suggests: no git, or a directory that is not a checkout, is
463
+ an ordinary state and never blocks a write. The one refusal left is when the target would resolve
464
+ onto the home directory, which would write the user-scope file while calling it a project one.
465
+ `--scope user` always writes `~/.xmemory.json`.
466
+
467
+ `context` makes no request when nothing is tiered `autoload`, and resolves no credential
468
+ either — a hook running on every session start must not error in a project that was never
469
+ going to fetch anything. A server without the endpoint is treated as nothing to inject, so a
470
+ client ahead of its deployment degrades quietly instead of failing every session, as does a
471
+ binding this build cannot parse.
472
+
473
+ **`--timeout` bounds the whole command.** Not the request — *everything*: binding discovery,
474
+ the credential preflight, the git provenance lookup, name resolution, TLS, response headers and
475
+ body. It is a wall-clock limit, because it is the only bound a session-start hook has (POSIX
476
+ `sh` has no portable `timeout`) and a bound with an exception in it is not one. Expiry degrades
477
+ like every other failure: exit **0**, empty pack, one `warning:` line naming the budget.
478
+
479
+ `--text` prints the pack and nothing else, for a caller that will **wrap** it. Do not point a
480
+ hook at it directly: a hook's stdout is parsed as its control document on some events, so a
481
+ pack that happened to be valid JSON would be read as control fields rather than injected. A
482
+ companion editor-integration plugin wraps this output in the documented hook envelope its own
483
+ client expects; it ships from a separate repository and is not part of this one.
484
+
485
+ **No secrets.** An instance id is not a credential, so a *project* `.xmemory.json` is written
486
+ `0644` and is *meant* to be committed — the opposite of `.xmemrc.json` (`0600`, never
487
+ committed). `~/.xmemory.json` is written `0600`: it stays on one machine, and on a shared host
488
+ its instance names and purposes are nobody else's business.
489
+
490
+ **Scope precedence has one exception: your own entries are a ceiling.** A nearer scope normally
491
+ wins field by field, but a project file arrives by `git clone` and is the less trusted of the
492
+ two. So for any instance `~/.xmemory.json` lists, a committed file may make it **less** eager,
493
+ never more:
494
+
495
+ - **Tier.** Ranked `autoload` > `available` > `off`. A project may take your `autoload` down to
496
+ `available` or `off`; it can never raise `off` to `available`, or `available` to `autoload`.
497
+ **An omitted `tier` in your file means `available` and binds as one** — so `binding add --scope
498
+ user <id>` with no `--tier` caps that instance at `available` everywhere. If you want a
499
+ project's `autoload` to stand, say `--tier autoload` in your own file: your entry states the
500
+ most eager you are willing to be, not merely that you know the id.
501
+ - **`engage`.** Replaced by a nearer scope for every other instance, but not for one of yours —
502
+ otherwise a committed file could rewrite the cues attached to your own instance, which is a
503
+ quieter version of raising its tier.
504
+
505
+ Everything else (`name`, `purpose`) still merges nearest-wins, and a project may always add
506
+ detail to an instance you listed.
507
+
508
+ **File format** (v1). `id` is required; everything else is optional. Unknown keys are rejected,
509
+ so a typo fails loudly rather than being ignored.
510
+
511
+ ```json
512
+ {
513
+ "version": 1,
514
+ "instances": [
515
+ {
516
+ "id": "9c421cd4-77ab-4a51-a7e3-b8fd5d09d394",
517
+ "name": "Team Knowledge",
518
+ "purpose": "shared dev conventions",
519
+ "tier": "autoload",
520
+ "engage": ["a convention is learned or corrected"]
521
+ }
522
+ ]
523
+ }
524
+ ```
525
+
526
+ ---
527
+
528
+ ### Trace — the Console's operation traces
529
+
530
+ What the Console shows on an instance's **Operations** tab (a paged list), on **Inspect** (a
531
+ search across operations), and in each operation's detail pane, plus (for super admins) the LLM calls of a
532
+ session, which no Console view lists — fetched from the same Console endpoints, with this CLI's API key, and emitted **as the Console serves them**. Nothing is curated
533
+ or reshaped: the Console already curates per caller, and a field the Console adds shows up here
534
+ the same day. These documents are for scripts and agents; on a terminal they are only indented
535
+ and coloured (`NO_COLOR` / `FORCE_COLOR` honoured). Flags only — there is no query language to
536
+ learn; the CLI composes the Console's search clauses itself.
537
+
538
+ | Command | Console view | Description |
539
+ |---------|--------------|-------------|
540
+ | **`trace ls`** | Operations tab / Home | Operations, newest first, across every instance this credential can see (nothing given — the Home view), or the `--instance ID` given (repeatable, or a comma list; default `--instance-id` / `$XMEM_INSTANCE_ID`). Returns `operations[]`, `counts`, `instance_counts`, `series`, and the paging cursor `next_before_ts_ms` + `next_before_trace_id` (with `has_more_operations`). Flags: `--trace-type T` (repeatable / comma; `write`, `read`, `create`, `update`, `evolution` — `delete` is `--search` only), `--hours-back N` (24 when not given, the Console's default for this list; `0` = unbounded) or `--start-ts` / `--end-ts` (epoch seconds), `--user-id`, `--api-key-id`, `--include-console-reads`, `--bucket-ms`, `--limit N` (default 100, Console cap 500), `--cursor TS_MS:TRACE_ID` (the previous page's two halves, joined with a colon), `--counts-only` (`ops_limit=0`: what Home fetches — counters and series, no rows; refused together with `--limit` / `--cursor`, which would have nothing to page). |
541
+ | **`trace ls --search`** | Inspect | The Inspect search, for the filters only it can answer: `--text` (free text), `--request` / `--response` (text on either side of the operation), `--object NAME` / `--field NAME` (repeatable; all must match), `--status S` (repeatable / comma; any may match: `ok`, `error`, `stale`, `timed_out`, `internal_error`, `quota_exceeded`, `in_progress`, `schema-gap`), `--trace-id ID` (repeatable / comma), `--session-id`; plus `--instance` (at most one — the Inspect scope), `--trace-type` (also `delete`), `--hours-back` (a floor applied to the hits *after* the search — Inspect's own recency windows decide the candidate pool, so this narrows a result, unlike the query window it is on `trace ls`), `--user-id`, `--api-key-id`, `--include-llm` (1.7.0: also model-call hits, in a free-text search — see below), `--limit N` (default 100, Console cap 500; a page can hold a few more rows than that when hits tie on time, trace id and instance id with its last one, and fewer when the Console narrows it), `--cursor TS_MS:TRACE_ID:INSTANCE_ID` (the previous page's three cursor fields, joined with colons). Returns the Inspect document: `merged[]` hits with `kind`, `href` (`/write/{trace}`, `/read/{trace}`, `/create/…`, `/update/…`, `/evolution/…`, or `#` for an instance deletion, which has no detail page), `trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id` / `api_key_name` / `api_key_prefix`, `status`, `quota_kind` (a `quota_exceeded` write or read only: `daily_quota_exceeded` or `monthly_quota_exceeded`; absent from an older Console), `time_taken_ms`, `match_items`; with `--include-llm`, for a super admin, model-call hits too: `kind: llm_call`, `trace_id` (the operation that made the call), `llm_call_id`, `llm_operation_type`, `href` (that operation's pane, or `#`), and `llm_sub_stage`, `llm_provider` and `session_id` when the call recorded them (the first two from a Console of 2026-10-08 or later); no `instance_id` and no `status`. Open one with `trace call <llm_call_id>`. Then the paging fields `has_more`, `next_before_sort_at_ms`, `next_before_trace_id`, `next_before_instance_id`, `truncated`. |
542
+ | **`trace calls`** | — (no Console view) | The LLM calls of one session, newest first, a page at a time. **Super admins only**: any other credential gets 403 → exit **3**, stage `permission`. `--session-id` (required), `--stage S` (exact, or a prefix with a trailing `*`, e.g. `benchmark.*`), `--model M` (the model slot, or the model that served the call), `--outcome` (`succeeded`, `failed` — an error that is not a refusal — or `refused`), `--hours-back N` (server default 720, i.e. 30 days; `0` = everything kept), `--limit N` (default 100, Console cap 500), `--cursor TS_MS:TRACE_ID:INSTANCE_ID` (the previous page's three cursor fields: the call time, its operation and its call id). Returns `llm_calls[]` — per call `llm_call_id`, `operation_id`, `operation_type`, `instance_id`, `session_id`, `sub_stage`, `model_type`, `model_id`, `provider`, `provider_type`, `called_at` / `called_at_ms`, `time_taken_ms`, token counts, `is_success`, `is_refusal`, `is_cached`, `error_message`, `finish_reason`, `gateway_request_id`, `client_attempts`, `gateway_repairs`, `reissue_of_llm_call_id`, `reissue_reason`, the requested settings, the applied ones (`applied_temperature`, `applied_thinking`, `applied_effort`), the prompt-cache counts (`cache_read_tokens`, `cache_write_tokens`) and the prompt renderer (`prompt_family`, `prompt_generator`, `prompt_schema_provider`, `prompt_version`); never the prompt or the reply (an `error_message` may quote the gateway's detail about a reply) — and the paging fields `has_more`, `next_before_sort_at_ms`, `next_before_trace_id`, `next_before_instance_id`, `truncated`. |
543
+ | **`trace call`** `<llm_call_id>` | — (no Console view) | One LLM call whole, as `{"found": true, "llm_call": {…}}` (so `jq .llm_call.prompt`): the same fields as a `trace calls` row plus `prompt`, `json_schema`, `result`, `raw_result` (the reply as the model wrote it, when the gateway repaired it) and `rejected_attempts`. It does not list the calls that re-issued this one (`trace get` on the operation shows those as `reissued_as_llm_call_ids`). If the call's stats never landed, `llm_call` carries only `llm_call_id`, `operation_id` and those logged fields. **Super admins only**, as above. An unknown id — the Console's `{"found": false}` — is `stage: "not_found"`, exit **3**; so is a listed call whose logged half (prompt and reply) never landed. |
544
+ | **`trace get`** `<trace_id>` | Detail pane | One operation's full document. `--trace-type` (`operation_type` on a `trace ls` row; the first path segment of a `--search` hit's `href`) makes it one request; without it the five detail endpoints are probed in order `write, read, create, update, evolution` and the first that knows the trace answers. A deletion's `DL00000000_aaaaaa` (or original `DL0000_aaa`) id is answered without a request: it has no detail page. A write's document carries `extracted_objects` (what extraction produced) and `cleaned_objects` (what the write kept after cleaning, rendered as a synchronous SDK `/write` answer renders it, by the caller's own primary keys; the answer may list fields and a relation's endpoints in another order), each a list holding one JSON document. A keyless object's `#N` in `cleaned_objects` is numbered within that field, as in the `/write` answer (over the cleaned objects, plus a structured write's relation endpoints), so it can name another object than the same `#N` in the document's `diff_plan`, `identity_changes` and `written_objects`: a keyed object joins across these fields by its primary key, and a keyless one cannot be matched across them by `#N`. `cleaned_objects` is `[]` for an edit, a failed write, a write still in progress, a write that cleaned nothing or whose objects could not be rendered, a write on an instance without a schema, and writes recorded before the Console stored it. Both go to everyone who may open the write. A miss — the Console's `{"found": false}`, or its 404 for a trace outside your scope — is `stage: "not_found"`, exit **3**. |
545
+
546
+ **`--search` is opt-in, on purpose.** The Inspect search runs text predicates across the trace store with a candidate pool of thousands and a merge — markedly more expensive than the indexed, cursor-paged `trace ls`. Passing a search-only flag (`--text`, `--request`, `--response`, `--object`, `--field`, `--status`, `--trace-id`, `--session-id`) without `--search` is a usage error (exit **2**) whose message says exactly that, so an agent reasons about whether it needs the search or whether `trace ls` narrowed by `--instance` / `--trace-type` / `--hours-back` plus `trace get` will do. Conversely `--counts-only`, `--start-ts` / `--end-ts`, `--bucket-ms` are refused with `--search` because the Inspect endpoint cannot honour them, and `--include-console-reads` because Inspect's only spelling of that toggle is a bare phrase inside `q` itself — which is also why a free-text value containing the words "include console reads" is refused rather than sent (the Console would flip the toggle and strip the phrase from the query).
547
+
548
+ **Breaking change in 1.4.0:** `trace ls --search` now asks the Console for one *page* (100 rows by default) instead of the whole capped result, under the Console's paging version 2 (`paging=2`), in which every search mode pages. A single call is no longer the complete answer: loop on `has_more`, and do not read a row count, or an absent `warnings` array, as completeness. A Console that predates paging still answers whole and capped, and the CLI tells it apart by the absence of `has_more`, not by the rows: a Console that pages sends the paging fields even when it found nothing. What it then warns about depends on what that Console reports. One that reports `truncated` gets a warning only when it says `truncated: true`, naming the ceiling of its mode; `truncated: false` is a whole answer and gets none. One too old to report `truncated` gets a warning on every answer: the CLI's own estimate when the row count reaches a ceiling, otherwise that this Console does not page searches and its answer may be capped.
549
+
550
+ **Paging a search — read this before scripting `--search`.** `trace ls --search` pages like `trace ls`, with the Inspect endpoint's own keyset cursor: pass the previous page's `next_before_sort_at_ms`, `next_before_trace_id` and `next_before_instance_id` back as `--cursor TS_MS:TRACE_ID:INSTANCE_ID` until `has_more` is `false`. A model-call hit has no instance, so when a page ends on one, `next_before_instance_id` is its `llm_call_id`: pass it back as it came, and give `--include-llm` on every page of that walk, since the cursor is a place in the result that flag shapes. Follow `has_more`, never the row count: the Console narrows each page after its row limits (type and status resolution, console-read exclusion), so a page can hold fewer rows than `--limit` — even none — and still have more. Paging reaches every match with two exceptions. Free text (`--text`) shorter than three characters does not match the text inside finished operations — a write's text and diff plan, a read's query and result, an operation's error message: the Console's text indexes on them hold three-character runs (trigrams), and rather than scan every payload it lets a needle with none match nothing there. The Console counts only ASCII characters toward the three, so a short needle with accented letters can miss the same way. Such a needle still matches an id, an exact object, field or type name, and an evolution's stage or error. The other exception the Console reports itself: a search with no filter that shapes the query (only scope such as `--instance`, `--session-id`, `--user-id`, `--api-key-id` or `--hours-back`) is the Console's recent-activity view, which reaches back only as far as its window (currently 180 days). When this credential has older operations than that, the last page carries `truncated: true` and the CLI emits a **warning** (stderr, and folded into the JSON document's `warnings` array) naming the way past it — a filter such as `--trace-type` or `--text`. That flag comes from the endpoint, which knows what it could not reach; against a Console that pages, the CLI does not guess at a cut from how many rows came back. Only a Console too old to report `truncated` still gets the CLI's own estimate from the row count, and that warning says it is an estimate. Asking for a page changes two things worth knowing before you script it: a free-text search answers from one candidate window per page rather than the exhaustive second pass the whole-result path ran, so a rare needle can give a short — even empty — first page with `has_more: true` where one call used to find the match; and a paged request is all-or-nothing, so a Console-side source failure is a `502`, or a `503` when ClickHouse refused the query for resources, rather than a partial answer at exit 0. A `--cursor` request answered without the paging fields reached a Console that does not page: a node not yet updated while a deploy rolls out, or a Console that no longer pages searches. It ignored the cursor, so the CLI exits **3** with `stage: "paging_unsupported"` rather than hand back its newest rows as the next page. Retry after a short wait; if it persists, restart the walk without `--cursor`. Each page is a full search on the Console, run again from the cursor, so a walk costs about one search per page: walk with `--limit 500`, the Console's cap, to make fewer calls. A clause-only search (a clause flag such as `--object`, `--field`, `--request`, `--response`, `--status` or `--trace-type`, and no `--text`) reaches up to `--limit` candidates per call — 100 by default, 500 at most — where the whole capped answer of a Console before paging reached 5 000 per source; paging reaches the rest. The CLI follows no page on its own: it returns the page it was given, even an empty one with `has_more: true`, and a script loops. The CLI does not ask for the Console's whole-result `totals` (`include_totals`, which the counts and charts on the Console's instance tab and session page draw); to count a search's matches, walk its pages.
551
+
552
+ **Model-call hits (`--include-llm`, 1.7.0).** Opt-in: without the flag a search asks for none, and its request and answer are what they were before 1.7.0. With it, a free-text search (`--text`) also looks through model calls — text in their prompt or error message, or the whole of their call id, operation id, session id or gateway request id — and returns the ones it matches beside the operations, newest first with them, so calls take places on each page. The Console looks through model calls only in a free-text search, and a model call has no instance, user or key to scope it by, so the CLI refuses `--include-llm` without `--text`, with a clause flag (`--trace-type`, `--status`, `--object`, `--field`, `--trace-id`, `--request`, `--response`), and with an instance, user or key scope (`--instance` or a bound `--instance-id` / `$XMEM_INSTANCE_ID`, `--user-id`, `--api-key-id`): each of those would answer with no model call whatever matched. `--session-id` and `--hours-back` work with it. A model call carries its prompt and error, so the Console serves these hits to a caller with no instance restriction only — a super admin's key — and leaves them out for every other credential rather than refuse the search: a member's key gets the same operations it gets without the flag, and no model-call hits. `auth status` says whether this credential is a super admin's. Each hit names its call: `trace call <llm_call_id>` opens it whole, and `trace get <trace_id>` the operation it belongs to.
553
+
554
+ **LLM calls of a session (1.6.0).** The list holds the calls recorded under that session; a call whose session was not recorded with it is not listed, so treat the list as the session's recorded calls, not a guaranteed complete count. The API records each call's session with the call (a session over 256 characters only best-effort, through the Console's lookup by operation); a per-step benchmark call is listed only when its runner sent a session with it, and a schema generation or instance creation call only when its operation was recorded with a session. `trace calls` pages exactly like `trace ls --search`: follow `has_more`, pass the three `next_before_*` fields back as `--cursor`. On the last page the Console sets `truncated: true` when calls matching the filters exist before the `--hours-back` window, and the CLI warns (stderr, and the JSON document's `warnings` array) — widen the window, or pass `0` for everything kept. The older text fields (`sub_stage`, `model_type`, `provider`) read `""` when nothing reported them, as in `trace get`; every newer one (`instance_id`, `model_id`, `provider_type`, `finish_reason`, `reissue_of_llm_call_id`, `reissue_reason`, `schema_property_order`, `applied_temperature`, `applied_thinking`, `applied_effort`, `prompt_family`, `prompt_generator`, `prompt_schema_provider`, `prompt_version`) is `null` then, so `select(.reissue_of_llm_call_id != null)` keeps only re-issues, as it does on `trace get`. The counts, times and outcome flags every call has (`time_taken_ms`, the token counts, `is_success`, `is_refusal`, `is_cached`) are always numbers and booleans; a setting or count only some calls report (`max_tokens`, `temperature`, `no_cache`, `client_attempts`, `cache_read_tokens`, `cache_write_tokens`), `error_message` and `gateway_request_id` are `null` when nothing reported them, and so, in `trace call`, are `session_id`, `json_schema`, `result`, `raw_result` (null for every reply the gateway served as written) and `rejected_attempts`. These commands need a Console that serves them: an older one does not accept a key on these paths and answers **401**, which the CLI reports as exit **4** `auth`.
555
+
556
+ **Two document shapes.** `trace ls` returns the Operations-tab document; `trace ls --search` returns the Inspect document (above). They differ because the Console's two backends differ and this CLI does not reshape what the Console serves. Both carry the same identifiers per row (`trace_id`, `instance_id`, `session_id`, `user_id`, `api_key_id`, `status`), which is what `trace get` needs next.
557
+
558
+ **Every operation row carries its internal ids.** `operations[]` rows have `operation_type`, `trace_id`, `instance_id`, `timestamp`, `status`, `is_success`, `error_code` (super admins only: it is the server's own cause code, finer than the API's error codes), `session_id`, `user_id`, `user_email`, `api_key_id`, `api_key_name`, `api_key_prefix`, `time_taken_ms`, `text_snippet`, `object_names`, `field_names`.
559
+
560
+ > **Three of those never resolve for an API key**, super admin or not, and this is by design rather than a gap. `user_email` is always absent: the accounts `/users` lookup is closed to keys (an agent may learn who *it* is, never enumerate the organisation's users), so rows keep `user_id` and you resolve names yourself if you need them — and the Console refuses a key's `User:` search clause naming a whole address (`User:sarah@example.com`) with a `400` `user_email_filter_unsupported_for_api_key`; filter by `user_id` instead. For that reason `--user-id` refuses a value containing `@` as a usage error (exit **2**) on both `trace ls` and `trace ls --search`, rather than sending a filter no key can resolve. `api_key_name` / `api_key_prefix` resolve only for the caller's **own** keys, because `GET /api-keys` scopes a key caller to its owner's keys. So a super admin's key does *not* see literally everything its owner's browser sees: it sees the same rows and the same fields, with those three enrichments empty. Everything scope-bearing — which instances, which detail fields, `llm_calls`, xuid substitution — is identical. Detail documents add what the Console's pane shows: `source_id`, `write_mode` / `read_mode`, `scope`, `extracted_objects` (an array holding one `{objects, relations}` document, failed writes included; empty when no extracted objects reached the journal: a write that failed before extraction finished or extracted nothing, or one that timed out), `diff_plan` (sanitised unless super admin), `written_objects`, `migration_id`, `proposal_version`, `schema_version`, `stage`, `triggered_evolution {migration_id, evolution_trace_id, update_trace_id}`, `sql_queries` (the rows owned by the read's instance, for every caller who may open the read), and two tiers of gated fields. **Super admin only** — the accounts API decides, for a key as for a browser session (a key counts only when its owner and its creator are both active super admins): `llm_calls[]` (each model call the operation made; fields below), `step_decisions[]` (what each step of the operation decided, per item; below), `full_error_message`, the write's and read's cause code `error_code`, raw token counts, and unsanitised xuids. **Callers with no instance restriction at all** — a super admin, or a browser session on a `KINGDOM_LOCAL` Console; never a scoped key: `ddl_queries`, `schema_generation`, and the ownerless rows of `sql_queries`.
561
+
562
+ **Each `llm_calls[]` entry**, oldest first, is a call made under the operation's id that names the document's instance, or names none (calls recorded before the API sent one, and work that runs for no instance, such as a schema generation): trace ids are not unique, so another instance's operation can share the id. A call that names no instance carries `names_no_instance: true`, since it is listed by the operation id alone and may be that other operation's; a call that names the document's instance carries no such key. Its fields: `llm_call_id`; `sub_stage` (the pipeline stage that made the call); `model_type` (the model slot asked for) and `provider` (the one that served it); `called_at`; `time_taken_ms`; `prompt_tokens`, `completion_tokens`, `reasoning_tokens` (part of the completion, not added to it), `total_tokens`; `is_cached`, `is_success`, `is_refusal`, `error_message`, `gateway_request_id`; `prompt`, `json_schema`, `result`. Then what explains a call's outcome:
563
+
564
+ - `reissue_of_llm_call_id` and `reissue_reason`: this call re-issues the earlier call with that id, which failed with that error code. A call sent to another model after it was refused, blocked for its content, or answered with replies that kept failing its schema; a reply cut at its output ceiling sent again with a higher one; and on writes the same overflow sent to another model at the same ceiling: all are re-issues. `reissued_as_llm_call_ids` is the other direction: the calls in the same list that re-issue this one. So either call leads to the other, with no second request.
565
+ - `max_tokens`, `temperature`, `schema_property_order`, `no_cache`: the settings the call requested, not necessarily what the model ran with: depending on the model and its settings, no temperature is sent at all. `schema_property_order` is also null for a call sent without a schema.
566
+ - `finish_reason`: why the model stopped, in the provider's own words, not normalised. `end_turn`, `stop`, `STOP` and `tool_use` are normal stops; `max_tokens`, `length` or `MAX_TOKENS` means the reply hit its output ceiling, and `model_context_window_exceeded` means the prompt and reply outgrew the context window. On a failure it is set where the model side reports it: always for those two, and for a refusal, a content filter or safety stop, or a schema miss after the model side's own retry where it reports those too; null otherwise. Read a refusal from `is_refusal`. One exception to "the provider's words": a cached Gemini reply (`is_cached: true`) first stored before 2026-05-06 reads `stop` whatever Gemini reported.
567
+ - `rejected_attempts`: the replies rejected before this call's answer or error (one that missed the schema, say), as an array; a recorded value that is not valid JSON is served as its text.
568
+ - `model_id` and `provider_type`: the model id the slot pointed at for this call and its provider type (`zai-org/GLM-5` on `nebius`), named for this very call, whether it was answered fresh or from cache. A call refused before any model ran (a rate limit, say) still names the model its slot pointed at. A failed call names the model from the last answer that named one, and none when no attempt got such an answer (every attempt timed out or could not connect) or the call was cancelled. It is the model of that answer only: an earlier attempt's rejected replies may come from the model the slot pointed at before. `model_type` names only the slot, which can be pointed at another model; `model_id` is the model id the slot pointed at when it answered, which for some models is a vendor alias rather than a dated snapshot. Null when the call did not record it, never guessed from the slot.
569
+ - `client_attempts`: how many attempts were made for this call, the one that answered or failed included; an attempt that could not connect counts too. It is the calling service's own count, not something the model side reports. Null for a call that was cancelled or stopped before its first attempt, and for calls made without the usual model client (description generation, for one).
570
+ - `gateway_repairs`: what was done to the reply before it was served, in a fixed order (not the order it happened in): `repaired` (fields written as JSON text were decoded), `json_recovered` (the first of several JSON values was kept), `omitted_scaffolding:<field>` (a reasoning field the reply left out), `schema_not_enforced` (no grammar held the reply to its schema), `strict_fallback` (a strict request was refused and a second, non-strict one answered) and `answered_in_text` (the reply came as text, not as the tool call it was asked for); on a call the caller asked the gateway to hedge, also `hedged` (a second copy of the call was sent) and `hedge_served` (that copy's answer was served). The last three of the first six say how the schema reached the model rather than that the reply was changed, and the hedge flags say how the answer was fetched, so a non-empty list does not by itself mean a repair. A fresh reply reports them, so on a fresh successful reply that names its `model_id`, `[]` means none of these flags applied; on a cache hit or a call that names no model, `[]` means not reported. A failed call reports them where the model side reports what it did before the failure, so there `[]` means none applied or not reported. Re-encoding a reply (key order, whitespace, very large integers rounded) is never reported, and `schema_not_enforced` records the model side's own choice, not a verified grammar.
571
+ - `applied_temperature`, `applied_thinking`, `applied_effort`: what the model side actually sent the provider for the attempt that answered, or on a failure its last attempt: the temperature as sent, the thinking mode (`adaptive`, `enabled`, `disabled`, `level:<x>`, `budget:<n>`) and the effort, or `none` when it sent nothing for one; `other` stands for a value outside the model side's fixed lists. They can differ from the requested settings: a slot that drops temperature, thinking turned off on the retry after a reply missed its schema. Null when not reported (a cache hit, a call that failed before reaching a model, or calls recorded before these fields existed).
572
+ - `cache_read_tokens`, `cache_write_tokens`: how much of `prompt_tokens` the provider read from and wrote to its prompt cache; parts of `prompt_tokens`, never added to it. Null when none or not reported.
573
+ - `prompt_generator`, `prompt_schema_provider`, `prompt_family`, `prompt_version`: the prompt renderer the call's model slot resolved to when it was made: the two classes the slot's prompts and response schema are rendered with, as `module.QualifiedName`; the model family a Claude slot's prompts are picked by (null for other providers, whose prompts are picked per slot); and a 16-hex-digit version of the classes' source. The version changes whenever that source or any project module it imports changes, the model-call code included, so a new version does not by itself mean the prompt text changed; it never changes with the instance's schema. The version is null for a call the API made before it had computed it (the first moments after a restart, or after a repoint to a renderer no slot used before) or where it could not read the source. All four are null for a call that renders no prompt, for a call whose caller wrote the messages (a per-step benchmark call of purpose `judge`, `extract` or `synthesis`, and the LLM-as-judge endpoint), and for calls recorded before the API sent them. A `prompt_eval` call names the renderer of the API node that served it, which during a deploy can differ from the one whose `benchmark_prompts/prepare` rendered its messages.
574
+ - `raw_result`: the reply as the model wrote it, where a repair or recovery (`repaired`, `json_recovered`) changed its value; `result` holds the changed one. Null otherwise, and not reported on a cache hit or a failed call (a cache hit replays the repaired reply only).
575
+
576
+ A value the call did not record is `null`, and `reissued_as_llm_call_ids` and `gateway_repairs` are `[]`; calls recorded before these fields existed read that way throughout.
577
+
578
+ **Each `step_decisions[]` entry** is one record of what one step of the operation decided, oldest first (by `recorded_at`, then `sub_stage`). A step records each time it runs and decides something (a step that decided nothing records nothing), so one operation can hold several records of one step, and every record is served; none is merged into another. A write's deletion check records in its own process and again where the diff engine applies its gate, and a write retried whole under the same trace id (a stale-schema retry, a stall requeue, a stuck write claimed again) records every step again. Writes, reads and schema evolution record today. A write's extraction records too, under `extraction.` steps named like its model calls (`extraction.structured_output`, `extraction.description_merger`, `extraction.batch_field_extractor`, `extraction.field_verifier`, `extraction.relations_extractor`, `extraction.relations_verifier`, `extraction.deduplication`): what code decided about a reply (candidates folded into one, a reply set aside so the extracted values stand, connections dropped, a candidate left out of the write, a group of types split), and the verifiers' own rejections. Field verification records once per object type, when that type's field processing ends; a write that fails before then records none for that type. When the judge in front of field verification is configured but does not run on a write whose detection found something, the write records why, once, under the stage the judge's calls would journal under (`extraction.verification_judge`, or `shadow.extraction.verification_judge` in shadow mode): one item, `kind` `judge`, `decision` `not_run`, with `detail.not_run_reason` (`sampled_out`, `pinned_provider` or `config_unavailable`) and `detail.mode`. Extraction's items have the diff engine's shape: `kind` is `merge`, `object`, `relation`, `relation_type` or `type_group`; `decision` is `folded`, `skipped`, `not_held` (the verifier found the text does not state the object holds, so it is marked as one that does not), `kept_unverified`, `verifier_skipped` (field verification did not run because the judge in front of it cleared the object), `key_restored`, `rejected`, `uncastable`, `dropped`, `split` or `escalation_disabled`; `detail` holds field names, candidate numbers and counts (`candidate_count`, `candidate_numbers`, `kept_number`, `ignored`, `basis`, `filled_fields`, `conflicting_fields`, `relations_repointed`, `fields`, `nulled`, `omitted`, `connection`, `field`, `candidates_per_link`, `relations`, `types`, `halves`, `refused_max_tokens`, `error_code`), and no field's value except an object's key: in `key`, in a dropped relation's `links` (`Type(field='value')` per link), and a `description` for an object that has no key. `/extract` records its extraction the same way; it has no detail document of its own, so its records show only on a write's document when that write, on the same instance, reuses its trace id. A write's apply records what its merges did to the stored rows, under `write.merge_application`, once the write commits (a write that rolls back records none): one item per merge action, `kind` `merge`, named by the surviving row (the diff engine's `merge` items are named by the row merged away instead): its `object_type`, its `key` (absent for a type without a key, for a key with any part NULL or missing, which could name another row, and when the two rows were one) and its `xuid`, with `decision` `applied` and, in `detail`, the row merged away (`merged_away_xuid`), `relations_repointed`, `relations_dropped` by reason (`linking_the_merged_rows`: a relation between the two merged rows; `removed_by_this_write`; `duplicate`: one the survivor already held) and `filled_fields`, the survivor's empty fields filled from the merged-away row, by name; or `decision` `nothing_to_merge` with why in `reason` (the two rows were one, or the row to merge away was already gone). Schema evolution records under `operation_kind` `evolution`: review, decide and apply each journal their steps' records once, when the stage ends, under their own trace, so an apply's show on its update document (its trace is the update id), and the detached pass that resolves an apply's gaps afterwards journals its own under the same trace. Its steps are the two model calls' names, `gap analyzer` (gaps never analysed or stored with no concepts, rows a reply left pending, concepts the parser dropped) and `semantic gate` (what the gate may do with each suggestion, candidates kept without a verdict, verdicts downgraded or not acted on), and the rule steps `evolution.backlog`, `evolution.resolution_filter`, `evolution.clustering`, `evolution.consolidation`, `evolution.prior_decisions`, `evolution.prerequisites`, `evolution.evidence_retirement` and `evolution.apply`; `kind` is `gap` (named by its feedback id in `key` and its concept's object and field), `suggestion` (its fingerprint in `key`, its change's names, and the gaps it was built from in `detail.gaps`, the first 20 with `gaps_total` when there are more), `cluster`, `decision`, `backlog`, `reply_entry`, `build` or `gate`, and no item carries what a user wrote. What a transaction did (a gap resolved, stored with no concepts, deleted or retired) is recorded only once it commits, and a row only when that step's own write or delete reached it. A build retried on a stale schema records both attempts: once the build runs again, every item the stage notes until it ends, the build's and the stage's own after it, carries `detail.attempt` (`2`), so an item without `attempt` was noted before the build ran again, and a `retried` item under `evolution.consolidation` records that it did; an `omitted` item is a marker, not a decision, and carries no `attempt`. A read records what the reader's own rules decided, under the name of the model call whose reply a decision is about (`read.question_decomposer`, `read.sql_syntax_fixer`, `read.empty_result_reviewer`, `read.overfetch_reviewer`, `read.answer_formatter`) or under a rule step (`read.scope`, `read.vector_grounding`, `read.sql_rewrites`, `read.sql_guard`, `read.sql_retries`, `read.answer`, and `read.other` for a family no step claims yet); a decision about a SQL statement a model call wrote goes under `read.sql_rewrites`, with the calling step in `detail.phase` where the rule names it. A read step that decided more than 101 times keeps its first and last 50 decisions, with one item (`decision` `omitted`, `detail.count`) where the rest were left out, cut further from the middle when they do not fit one record. A read journals its records once, when it ends, so a read still running has none yet, and a read retried on a stale schema runs again under the same trace id and records every step again; its items have `kind` (the decision family, such as `sql_guard_refused` or `distinct_order_by_case_dropped`), `decision` (`applied` or `not_applied`), `reason` where the rule says why, and `detail.part`, the part of a decomposed question the decision was made for, counted from 1, where it was made for one. `sub_stage` names the step in the `llm_calls[].sub_stage` vocabulary, so a step's decisions and its model calls join on it (a re-issued call journals under its step's name with a suffix such as `.refusal_fallback`, so it joins on the name as a prefix; the reader's rule steps, `extraction.deduplication` and `write.merge_application` have no model calls of their own) (`diff_engine.keyless_binding`, `diff_engine.deletion_check`, `diff_engine.intent_rules`, `diff_engine.value_span_containment`, `diff_engine.intent_verification_deep`, `diff_engine.alias_confirmation`, `diff_engine.object_resolution`, `diff_engine.unwritten_identity_verification`, `diff_engine.plan_construction`, the intent pipeline's `diff_engine.intent_gate`, `diff_engine.intent_operations` and `diff_engine.operations_verification`, and the `read.` steps above). A model call made in shadow mode, beside the work it never changes and never charged, carries the prefix `shadow.` on its step's name; the intent pipeline and the field verifier's judge are the steps that run in shadow today. The field verifier's judge, which can stand in front of `extraction.field_verifier` on a DEEP write or `/extract`, makes its calls under `extraction.verification_judge`, or `shadow.extraction.verification_judge` in shadow mode, and records under that same stage, once per object type, one item per object it was asked about: `kind` `object`, `decision` `flagged` (a field at or over its threshold, or one no question covered), `cleared` or `not_judged` (no verdict, which counts as every field flagged), with `detail.field_scores` (the flagged fields the judge scored, and their scores), `detail.unjudged_fields` (flagged fields no question covered), `detail.score`, `detail.field` and `detail.question` (the top score and where it came from), `detail.threshold`, `detail.questions_asked` and `detail.verifier` (`ran`, `ran on the flagged fields and the key`, or `skipped`). In on mode the verifier is asked only about the flagged fields and the key; in shadow mode it sees every field, and the item says what on mode would have sent it. A `verifier_skipped` item under `extraction.field_verifier` is an object an `extraction.verification_judge` call of the same operation cleared; every question and answer is in that call in `llm_calls[]`. The intent pipeline names its three model calls by its step names above, with the prefix `shadow.` in shadow mode; `diff_engine.intent_operations` records decisions in on mode only, where it stands in for `diff_engine.intent_rules`, and `diff_engine.intent_gate` (with the prefix in shadow mode) records one item when the gate's deadline, the write's grace, opened it: `kind` `intent_gate`, `decision` `opened_by_deadline`, and `detail.deadline_seconds`, `detail.unanswered_calls` (the gate calls cut off) and `detail.sentence_count`. A step whose decisions act on another step's calls names itself the same way: `diff_engine.intent_rules` acts on the replies under `diff_engine.object_and_relations_deletion_detection` and `diff_engine.batch_field_erasure_detection`. The records hold what the engine decided that the operation's `llm_calls[]` (their prompts and replies) and its `diff_plan` do not already show, so the model's own answers are read in `llm_calls[]`, not here. `decision_count` is the step's true total and `decisions_truncated` marks a record cut to fit (for a read step, also one cut to its first and last 50 decisions), so `decisions` can hold fewer items than the count. `decisions` is the items as the step recorded them, a JSON array, or `null` when the stored text is not one (the count still shows). A pane serves at most 500 records and 4 MiB of `decisions` text, the newest; when it cut older ones it adds `step_decisions_truncated: true` beside the array, and otherwise the key is absent. A trace id a client reuses across writes serves every write's records together, ordered by time with nothing between the writes. The diff engine's items share one shape: `kind` (what the item is: `intent_gate`, `object`, `identity_change`, `merge`, `filtered_deletion`, `filtered_update`, `relation`, `deletion`, `alias`, `scope`, `detection`, `operation`, `intent_pipeline`) and `decision` (what was decided: `deletion_dropped`, `converted`, `rejected`, `kept`, `bound`, `not_bound`, `matched`, `left_out`, …), then, where set, `object_type` and `key` (the primary-key values as `field='value'`), `index` (the object's position in the diff engine's sorted object list, not in `extracted_objects[]`), `xuid` (the stored row), `reason`, and `detail` (anything else the step decided). `diff_engine.keyless_binding` records one `object` item per record of a keyed type that states no key but another field: `bound` to the one stored row it names (its `key` and `xuid`), or `not_bound` with why in `detail.outcome` (a record one stored row matches in every field it states carries that row's `key` and `xuid` when it is left `not_identifying`, matching only in yes/no and listed-value fields and never put to the verifier, or `rejected` or `unverified`, the verifier not confirming the text refers to it); `same_key_group` (the write holds records of different types with the same key, so nothing of it is looked up), `key_stated_for_another_type` (a record of another type in the write states the key of the row it would bind to), `key_of_another_type` (a stored row of another type has that key) and `key_check_unread` (the stored rows could not be read, or the key has more fields than the check looks up) are a temporary net against extraction copying a record's identifier onto another type; a binding left unbound for one of the last three carries the row it would have bound to (its `key` and `xuid`; a record not asked about its closest rows for them lists those rows in `detail.other_type_rows` instead), and the other types in `detail.other_types`: for `key_stated_for_another_type` and `key_of_another_type`, and for a record set aside unasked as `key_check_unread` whose rows stand down for different reasons, the types its rows' keys were found under. The same-key groups and the stated-key check go once extraction no longer returns such copies; the stored-key check goes only once the copies earlier writes stored are cleaned up too, or a scan finds none. A stored copy whose key differs only in case, surrounding spaces or accents is not found. In on mode `diff_engine.intent_operations` says what the write did with the intent pipeline's answer: an `operation` item per operation it applied, its `decision` the operation's kind (`delete`, `unlink`, `move`, `clear`, `rename`, `merge`, `bulk_delete`, `bulk_update`) and its `reason` the passage that asks for it, then `not_applied` for one the write could not carry out (a delete of a record by the new name a rename of the same write gives it reads "it is also the target of a rename", as the engine's own rule says it) and `dropped` for one the pipeline dropped, each with why and `detail.position` (its place in the model's reply), then an `object` item `skip` for each record of a keyed type that states no key and that the write skipped, and `erase_refused` for each record bound to a stored row by its other fields whose clears were not applied (`detail.fields`); a `clear` item therefore names what the pipeline asked for, not what was written. When the engine's own detection decides the write instead, the step records one `intent_pipeline` item, `decision` `engine_decides`, whose `detail.fallback` names why (its `reason` says it in words) and beside which the engine's own steps record as with the pipeline off: `missing` (no result reached the write), `unreadable`, `translation` (the rows the operations name could not be read), `dropped` (the pipeline dropped an operation it proposed for a reason other than its verifier's rejection, so its answer is not the whole change), `verifier_rejected` (a temporary net: the pipeline's verifier rejected a delete, a clear, an unlink or a move it proposed, which may be about how the operation names its records rather than the change; a rejected bulk delete or update does not count, and a rename it rejects, or a merge while `execution.entity_merge` is on, goes to the engine as `rename_or_merge`), `unresolved` (it saw a change whose records it could not name: a record described rather than named, a key stated in part, records selected through a relation, every link of a kind), `rename_or_merge` (it holds a rename sharing a record with a merge, or renames that swap, cycle, fork or converge, decided before the pipeline's verifier is asked, or the verifier rejected a rename, or a merge while merges are on), `rename_old_key_unmatched` (it renames a record that no single stored record has by the name the text gives), `merge_loser_unmatched` (it merges away a record that no single stored record has by the name the text gives), `rename_onto_stored` (it renames a record onto a name another stored record already has), `rename_record_partly_stated` (the write states a record without the rest of a key a rename names, so whether it is that rename's old record, its new record or another one is unknown), `delete_with_values` (it deletes a record the write's own extraction gives values other than its fields' defaults), `unlink_replaced` (it unlinks records while the write's own extraction holds another record the link may have moved to, which no new link joins to the unlink's other ends and no other operation ends or removes), `key_rewritten` (an operation's key was read as the extraction's spelling), `unmatched_key_only` (the gate opened, while the extraction holds a record by its key alone that the answer would write and no stored row has; a delete's record, a rename's old and new records, a merge's survivor and a move's new end are not looked up), `key_only_lookup_failed` (the same, but whether those records are stored could not be read), `end_not_stored` (an unlink's end, or a move's old or kept end, binds no single stored row: none has its key, or several do, so the stored link would stay), `rename_or_merge_rejected` (the write's second verification did not confirm every rename and merge of the pipeline's answer, so the engine's own detection decided it again from the start; a temporary safety net, until the pipeline binds the records its operations name itself; it goes together with `unmatched_key_only` and the end guard, which would otherwise send most such writes to the engine), `end_not_extracted` (a move's new end is neither a stored row of its type nor a record the write's extraction holds, by its key: a key the pipeline made up, such as a name or part of an id), or the step a run without an answer stopped at (`gate`, `operations`, `verification`, `executor`, `late`, `not_run`). Beside `fallback`, `detail.dropped_operations` lists each operation the pipeline dropped (`position` in the model's reply, `operation` kind when readable, `reason`), and `detail.unresolved_changes` each change it could not name the records of (`position`, `evidence`, the passage that asks for it, and `object_type` when the model gave one). `dropped_operations` carries no field values; an `unresolved_changes` entry's `evidence` is a passage of the write's own text, as an `operation` item's `reason` is. `diff_engine.intent_rules` logs what the engine's rules did to the detection model's answer, in the order they acted, so an item can be dropped and later restored (a rename whose old record is stated by part of a composite key is an `identity_change` item `completed`, bound to the one stored row its stated part matches and named by that row's `xuid`, or `not_bound`, left as stated so the write goes as it would without the step: no stored row or several match, the one row disagrees with the new record, a stored row with a NULL or blank key part matches, the database could not be asked, a value is not one its field can hold, the new record is of another type than the rename's, no index serves a lookup by the stated part, another rename or a merge lands on the same new record, or something else in the write may name the row or the key, its kind in `detail.named_by`); `diff_engine.plan_construction` says what building the plan decided after every earlier step, which the plan shows only by leaving it out (a merge the relation-budget re-check refused or re-admitted after alias binding, a deletion a rename overrides, an unlink dropped for a row a confirmed delete does not run on or a lookalike row the write does not bind, a move whose old link stays with the replacement guard's cause, a dropped merge or one already a single row, what a scoped write left out). A deletion that verification rejected reads `kind: "deletion"`, `decision: "rejected"` under `diff_engine.intent_verification_deep`, with what the object became and why; the proposal itself is the detection model's reply in `llm_calls[]`. Another part of the system may record items of its own shape here; they are served as recorded. Every detail pane serves its operation's records the same way; the write, read and evolution panes serve them for an operation still running or stale too (create and update have no such pane). A step that refuses the write still records what it decided first: the detector its rules, resolution what it found, plan construction the merges, unlinks and scope it changed. A caller who is not a super admin gets neither `step_decisions` nor `step_decisions_truncated`, exactly as for `llm_calls[]`.
579
+
580
+ **A scoped key gets `[]` for `ddl_queries` / `schema_generation`, not the rows.** Those two come from records that carry no instance ownership — the schema generation's trace id is even client-supplied — so they cannot be scoped to an instance and are served only to callers with no instance restriction at all. This is a deliberate fail-closed choice, not a bug to work around: an empty array means "not available to this credential", never "this operation ran none". `llm_calls[]` behaves the same way (gated on super admin) and always has. `sql_queries` is split: statements recorded with their owning instance reach every caller who may open the read, even when another instance reused the trace id, while statements recorded before ownership was tracked carry no owner and reach unrestricted callers only. A scoped key therefore sees `[]` for reads that old. To let a script tell the two apart, the Console sets a sibling boolean next to each — `sql_queries_withheld`, `ddl_queries_withheld`, `schema_generation_withheld` — that is `true` exactly when a row that exists was withheld from this caller: for `ddl_queries` / `schema_generation` that is every scoped caller, for `sql_queries` a scoped caller on a read that has ownerless rows. So `[]` with `…_withheld: false` is "none recorded", `[]` with `…_withheld: true` is "not yours to see". **Xuids are hidden for every non-super-admin**, including on writes that carry no identity map — the substitution is done by the Console, server-side, so a caller with no browser sees `#hidden` rather than a raw id.
581
+
582
+ **Who sees what — the Console decides; `auth status` tells you which view to expect.**
583
+
584
+ - A regular key sees the operations of the instances the accounts API lists for it — the same set `org list instances` prints. That is at most what its owner sees in the Console, never more.
585
+ - Access requires the key's role to carry **`data.read`** — the same permission the data-read endpoint requires. A role deliberately scoped away from data (`auth status` shows the codes under `permissions`) is refused with **403 → exit 3, stage `permission`**, whoever owns the key — a permission denial, not a bad credential: rotating or re-issuing the key cannot fix a role that was scoped this way on purpose. An unknown or missing key is the separate 401 → exit 4 (`auth`).
586
+ - A key owned by an active **super admin** and created by one sees the super-admin Console: every organisation's instances, no instance filter, and the super-admin-only detail fields above. A key an organisation's Owner created for a super admin (an Owner may create keys for its members) is scoped like any other key. The accounts `/whoami` `is_super_admin` decides — the key sees the same rows and the same scope-bearing fields its owner's browser session sees, minus the three enrichments no key resolves (`user_email`, and `api_key_name` / `api_key_prefix` for other people's keys; see the note above). `auth status` reports it as `super_admin`.
587
+ - When the Console **cannot** work out that scope — its accounts API is unreachable, answers non-2xx, is undecodable, or is not configured — it answers **503** with `{"error": "instance_scope_unresolved", "reason": "accounts_…"}` (or `accounts_unavailable` when the key's own lookup failed the same way) instead of an empty document. The CLI reports that as **exit 3, stage `unavailable`**, with a message that says the scope could not be resolved and that this is *not* "no operations": a zero-row answer from a Console that could not decide would have read as a quiet instance.
588
+ - No new permission is involved: the Console answers a key as it would answer its owner's browser session, narrowed to the key's instances, and treats it as a super admin's only when its owner and its creator are both active super admins. It never mints a session for a key. Filters that need to look other users up (`User:`-style email filters) are not offered: an API key can identify only itself, never list or look up users — filter by `--user-id`.
589
+
590
+ **The Console origin.** `trace …` talk to the Console, a second origin. It is `--console-url` → `$XMEM_CONSOLE_URL` → inferred: `https://console.xmemory.ai` for the default API, the `/console` sibling of a `/xmem` base, `https://console.stg.xmemory.ai` / `https://console.pp.xmemory.ai` for the stg / pp APIs, `:8002` for a local `:8000`. A key bound to any other API origin gets a usage error (exit **2**) rather than a guess — the credential-binding rule holds across origins.
591
+
592
+ **Parity.** These commands are the Console's data path, not a copy of it. If the Console gains a filter or a field, it is added to the same list here and is available; the MCP server exposing the same data is deferred and will follow the same rule (see the note in the MCP server source).
593
+
594
+ ---
595
+
596
+ ### Auth & meta
597
+
598
+ | Command | Description |
599
+ |---------|-------------|
600
+ | **`auth login`** | PKCE via Console `/xmemcli/<challenge>` → `.xmemrc.json`. `--auto-approve` (1.7.4+: the same page opened with `?auto_approve=1&host=<hostname>`; once the browser holds a Console session the Console hands the CLI a code with no click, and a key named `CLI auto-approved login (<hostname>, <timestamp>)` is minted when the CLI redeems it at `/cli/token`, once per login; refused beside `--email`; the payload carries `auto_approve: true`), `--no-browser`, `--timeout`, `--console-url`, `--callback-port` (pins the loopback callback port for `ssh -L`; `0` or omitted means any free port, otherwise 1024–65535; also `$XMEM_CALLBACK_PORT`, which is read globally). An explicitly named Console (`--console-url`, `$XMEM_CONSOLE_URL`, or the one pinned by a single-origin URL) is cross-checked against the API origin before the browser opens or a pasted key is sent anywhere — a provably mismatched pair (mixed loopback-ness, a production half paired with a non-production one, or a stg / pp half paired with anything but its own origin; a single-origin `/console` beside its own `/xmem` on one origin is one deployment, even on a stg or pp host, while the production halves are still held to their defaults) is a usage error (exit **2**), on the interactive paste path as much as on the browser path. When the API comes from the rc: an origin that is neither https nor loopback (`localhost`, `127.0.0.1`, `[::1]`) is refused (naming a bare origin with `--api-url` beside an https `--console-url` accepts it), and if that API names its own Console (a `/xmem` base its `/console`, a local `:8000` its `:8002`, stg / pp their table Console), a named Console must be that one, in any spelling of that origin (case, a default port, trailing dots on a named host, `localhost` / `127.0.0.1` / `[::1]` for a loopback one) but on the same mount — any other is refused (exit **2**), so a `$XMEM_CONSOLE_URL` exported for one deployment cannot mint a key into the rc of a deployment whose Console is known. Naming the API on the call makes the pair the caller's: `--api-url` with the Console in `--console-url`, or `$XMEM_API_URL` with it in `--console-url` or `$XMEM_CONSOLE_URL`. With no `--api-url`, `$XMEM_API_URL` or positional URL, the API origin is the `api_url` of the nearest `.xmemrc.json` (see the 1.7.1 note below), and both the browser and the `--email` path print that origin and the file it came from before anything is sent — except for the production default, where nothing is printed. With `--email ADDR` (+ optional `--org-name`, used only when a new account is created; requires `--email`): headless email sign-in — one emailed approval, then the CLI mints its own org-wide key named `CLI email login (<hostname>, <timestamp>)`; details in the auth-flow reference. New-organisation creation additionally requires the deployment to allow public registration. Payload shapes: a fresh email mint carries `method: email`, `key_name`, `api_role`, and `org_created`; the browser path carries `method: pkce` (no role/org fields); a matching `--api-key` or rc credential short-circuits to `status: already_authorized` with `source`: flag / rc (`persisted: false` on the flag arm, which saves nothing); a matching `$XMEM_API_KEY` is copied into the rc and reports `status: authorized`, `source: environment`. Exit codes follow the standard HTTP contract (401 → 4 with stage `auth`, 403 → 3 with stage `permission`, other HTTP/network/server-state failures → 3). |
601
+ | **`auth logout`** | Remove active `.xmemrc.json` |
602
+ | **`auth status`** | Call `/whoami`. No key → exit 0; bad key → auth error; good key → email + prefix + `super_admin` (whether the key is a super admin's, meaning its owner and its creator are both active super admins — what decides which Console view `trace …` return) + `permissions` (the key's own capability codes; `trace …` requires `data.read` among them). A Console named in `$XMEM_CONSOLE_URL` is cross-checked against the API origin before the key is sent to it (see the 1.7.1 note below); a mismatched one exits **2**. |
603
+ | **`mcp <instance-id>`** | Serve one instance to an MCP client over stdio, using this CLI's credential. Not for humans — see below. |
604
+ | **`version`** | CLI release version (no network) |
605
+ | **`status`** | Local health: version + whether `.xmemrc.json` exists, and the `mcp_url` it declares when it declares one (no network) |
606
+
607
+ **`mcp` is a transport, not a command you run.** An MCP client starts it and speaks
608
+ JSON-RPC to it over stdio; it forwards each frame to `<mcp-url>/instance/<id>` with this
609
+ CLI's credential attached, and writes the answer back. The Console renders the
610
+ registration as `claude mcp add <name> -- xmemcli mcp <instance-id>` (with
611
+ `--mcp-url <origin>` appended when the MCP origin is not the client's own default), or the
612
+ equivalent for another client — **replace the bare `xmemcli` with its absolute path, in
613
+ double quotes**. The client stores the command as written and resolves it against its own
614
+ `PATH` every time it starts the server, and that `PATH` need not carry the directory the
615
+ tool was installed into; registered bare, it fails there — Claude Code reports
616
+ `ENOENT: Executable not found in $PATH: "xmemcli"`. `command -v xmemcli` prints the path on
617
+ macOS and Linux, `(Get-Command xmemcli).Source` in PowerShell on Windows. If the bare name
618
+ is registered already, remove that server entry before adding it again: Claude Code refuses
619
+ `mcp add` for a name that already exists rather than replacing it.
620
+
621
+ It exists because an MCP client can reference an environment variable in an
622
+ `Authorization` header but cannot read `.xmemrc.json`. Reading the credential per
623
+ connection means no credential is captured when the server is registered, so a
624
+ configuration written today still works in a session opened next week with an empty
625
+ environment.
626
+
627
+ **Where a file-held key may be sent.** The MCP origin is configured separately from the API
628
+ origin, and a project `.mcp.json` arrives by `git clone` — it controls that server's `args` and
629
+ `env`, so it is not allowed to vouch for itself. A key read from `.xmemrc.json` therefore reaches
630
+ only the MCP origin that same file records in `mcp_url`, the origin paired with its `api_url` in
631
+ this client's built-in table of shipped deployments, or a loopback host when both ends are on this
632
+ machine. Anywhere else needs a key named on the call with `--api-key`.
633
+
634
+ A recorded origin says *where*, never *how*: a remote one must be `https`, so a hand-written
635
+ `http://` entry is refused rather than sending the key in clear text. Loopback stays exempt — those
636
+ bytes never leave the machine.
637
+
638
+ The **mount path is part of the destination**, not decoration. A deployment serving every route
639
+ group on one hostname puts MCP at `https://host/mcp`, the REST API at `https://host/xmem` and the
640
+ OAuth server at `https://host/oauth2`; an rc recording the first authorises the first only.
641
+
642
+ `auth login` fills in `mcp_url` from what the API reports about itself, which is what makes an
643
+ on-premise deployment work: its MCP host is in no table this client was built with, and every
644
+ attempt to *derive* one host from the other has been wrong in a new way. If the transport refuses
645
+ an origin you expect it to accept, run `auth login` against that deployment again — the rc predates
646
+ the field, or that server does not report one. That works even when the stored key is still good:
647
+ the `--email` path records the origin it learns while checking, and reports it as `mcp_url` on the
648
+ `already_authorized` payload. A deployment that has stopped serving MCP answers `null`, and the
649
+ recorded origin is dropped rather than left authorised — and if that removal cannot be written,
650
+ the command fails rather than reporting success over a host that is still authorised.
651
+
652
+ Two consequences worth knowing:
653
+
654
+ - **stdout is the protocol.** Alone among these commands it emits no JSON document and
655
+ honours neither `--json` nor the exit-code table above; its exit code is a process
656
+ status. Diagnostics go to stderr.
657
+ - **Signed out is reported, not fatal.** Every request is answered with a JSON-RPC error
658
+ naming `auth login`, because a client that is told only "the server failed to start"
659
+ gives the reader nothing to act on. Exit code and stage do not apply.
660
+
661
+ **Console `/xmemcli` routing:**
662
+
663
+ - **Onboarding** → silent onboarding-key handoff to the CLI (no overlay, no button click).
664
+ - **Post-onboarding** → API Keys create modal → “Use this key in the CLI”.
665
+
666
+ ---
667
+
668
+ ### Quota (read-only)
669
+
670
+ | Command | Description |
671
+ |---------|-------------|
672
+ | **`quota`** | Usage / limit / window start for the key's cluster (derived from instance → cluster). |
673
+
674
+ - **Default:** exit `0` when quota is readable; stderr **warning** when usage is high.
675
+ - **`--fail-if-under THRESHOLD`** — exit **7** when **remaining** quota is below that share of the limit (`0.1` or `10%`). Distinct from exit **6** (API quota exhausted). See `xmemcli help quota`.
676
+
677
+ Quota **increases** are Console-only — see [What `xmemcli` does not do](#what-xmemcli-does-not-do).
678
+
679
+ ### Exit codes
680
+
681
+ | Code | Meaning |
682
+ |------|---------|
683
+ | **0** | Success |
684
+ | **2** | Usage error (bad flags, unknown `help` topic, missing required args) |
685
+ | **3** | HTTP / request failure — see [HTTP status mapping](#http-status--exit-code-and-json-stage) (includes permission denied, other API errors, and unwritable `-o` paths) |
686
+ | **4** | Auth error — invalid or missing API key (`401`; `stage: "auth"`) |
687
+ | **5** | Timeout |
688
+ | **6** | Quota exhausted (API quota-exceeded response; `stage: "quota"`) |
689
+ | **7** | Quota headroom below `--fail-if-under` |
690
+ | **8** | Write still processing — the API's wait window expired while the write kept running (HTTP `200` envelope; `stage: "processing"`). Not a failure: poll `write-status`, never retry |
691
+ | **130** | Interrupted (Ctrl+C) |
692
+
693
+ ### HTTP status → exit code and JSON `stage`
694
+
695
+ `xmemcli` mirrors the API's **401 vs 403** distinction on the process exit code and on the machine-readable `stage` field. This is the single contract for **every** command that performs an HTTP call (read/write, `org list *`, schema/XMD, quota, `auth status`, …) — with one deliberate exception, below.
696
+
697
+ **`context` is exempt, by design** — from the release that introduces `context`, which is the
698
+ first one where this carve-out means anything. It is the one command nobody invokes: a
699
+ session-start hook runs it unattended, and a non-zero exit there costs the user their session
700
+ rather than their context. So **every** HTTP outcome — 401 and 403 included — exits **0** with an
701
+ empty context and an explanatory `warnings[]` entry. Use `auth status` to tell a credential
702
+ problem apart from an empty binding; `context` will not.
703
+
704
+ The exemption goes further than HTTP status. Any runtime failure inside `context` — a malformed
705
+ binding, an unreadable response shape, a timeout, a bug nobody anticipated — exits **0** with an
706
+ empty pack and a `warning:` line naming the cause. This is enforced in code rather than by
707
+ enumerating failure modes, so an unforeseen one degrades like the rest.
708
+
709
+ **And it is exempt from the one-JSON-document rule on stdout.** Every other command emits a JSON
710
+ error document in machine mode, including on usage errors. A piped, non-`--json` `context` whose
711
+ arguments fail to parse writes **zero bytes to stdout** and puts its reason on stderr instead,
712
+ because a hook discards stdout at exit **2** — so a JSON error document there is a session start
713
+ failing with no stated cause anywhere the user can see it. Usage and parse errors are the only
714
+ paths that still exit **2**; they are a mistake in the hook's own configuration, not a runtime
715
+ condition, and the hook author is the one reading stderr.
716
+
717
+ #### Quick reference
718
+
719
+ | API HTTP status | Shell exit code | JSON `stage` | Meaning |
720
+ |-----------------|-----------------|--------------|---------|
721
+ | **401** | **4** | `auth` | **Bad credential** — missing, invalid, expired, or inactive API key |
722
+ | **403** | **3** | `permission` | **Bad authorization** — key is valid but this key/role cannot perform the operation |
723
+ | **Other 4xx/5xx** | **3** | `request` | Request failed for another reason (404, 422, 5xx, …) |
724
+ | **402** (quota exhausted) | **6** | `quota` | Quota window exhausted — human must extend quota in Console |
725
+ | **any status** with `errors[0].code == WRITE_STILL_PROCESSING` | **8** | `processing` | Write is still running — the write did not fail. Matched on the **code**, whatever the status: a keepalive-flattened **200** and an unflattened **500** both exit 8, so this row overrides the 4xx/5xx row above. Poll `write-status <write_id>`; a retry starts a second write |
726
+ | **503** with body `{"error": "instance_scope_unresolved" \| "accounts_unavailable", "reason": …}` | **3** | `unavailable` | The Console could not resolve what the key may see (its accounts API is unreachable, non-2xx, undecodable, or not configured). **Not** "no operations" and **not** a bad credential — nothing was listed or checked; retry once accounts recovers. Recognised on the body, so it is named the same on every command that meets it. |
727
+ | **200** to `trace ls --search --cursor …` with no `has_more` | **3** | `paging_unsupported` | The Console ignored the cursor and answered with its newest rows, so the answer is **not** the next page: a node not yet updated while a Console deploy rolls out, or a Console that no longer pages searches. Retry after a short wait; if it persists, restart the walk without `--cursor`. |
728
+
729
+ #### How to interpret failures
730
+
731
+ 1. **Exit 4 / `stage: "auth"` / HTTP 401** → fix the key: login again, rotate the key, check `$XMEM_API_KEY` / `.xmemrc.json`, confirm `--api-url` points at the environment that issued the key.
732
+ 2. **Exit 3 / `stage: "permission"` / HTTP 403** → the key works, but the operation needs a broader API role (for example `data.write`, `instance.generate_schema`, `cluster.get_own`). Do **not** treat this as a wrong password.
733
+ 3. **Exit 3 / `stage: "request"`** → other HTTP failure (instance not found, validation error, server error). Read the error message and HTTP status in the JSON body.
734
+
735
+ Some commands attach a more specific `stage` (`validate`, `usage`, `limit`, `not_found` for `trace get` and `trace call`, …) while still using exit **3** unless the row above applies. `write` adds `processing`, which is in the table above and exits **8** rather than **3**. The `schema suggestions` commands add their own stages: `stale_proposal` (re-run review for a fresh `proposal_version`), `invalid_decision`, `dependency` (accept the dangling dependencies first), `not_initialised` (submit a write first), and `apply`; `review` reports an `evolution_in_progress` condition as a successful status with exit 0.
736
+
737
+ **Envelope errors** (HTTP **200** with non-empty `errors[]`) are not HTTP 401/403; they typically exit **3** with `stage: "request"`. Two exceptions. `QUOTA_EXCEEDED` exits **6** with `stage: "quota"`, the same as it does on a real HTTP 402 — the keepalive flattening does not change the exit code — and this applies to every command that classifies the error, not just `write` and `read`. `WRITE_STILL_PROCESSING` exits **8** with `stage: "processing"` and is not a failure at all; unlike the quota rung this one is **`write`-only**, because its guidance is to poll `write-status`, which is meaningless for any other verb — elsewhere it falls through to **3**/`request`.
738
+
739
+ > **Known bug.** Some subcommands in the `instance` and `schema` groups — `instance get` and `schema get` among them — unwrap the response outside their error handling, so an envelope error never reaches the classifier and escapes as an uncaught exception: a traceback and shell exit **1**, which is not in the table above. Those report **1**, not the **6** this section describes, until that is fixed. It is not whole groups: `instance instructions`, for example, reaches the classifier and exits **6** as documented, and every heartbeat-wrapped command (`instance create`, `xmd generate`, `xmd enhance`, `schema update`, `schema dry-run`) unwraps inside its handler and exits **6**.
740
+
741
+ **Breaking change in 0.0.5:** HTTP **403** previously exited **4** (`auth`); it now exits **3** (`permission`). Scripts that treated exit **4** as “any auth-layer failure” must check `stage` or handle exit **3** for permission denials.
742
+
743
+ **New in 1.7.6:** `auth login --auto-approve` reads a stored callback only together with its capture signal (under the listener's capture lock), so a callback that landed in time can no longer be seen before its timestamp and mistaken for none; a fix to 1.7.5's deadline handling, with no other change.
744
+
745
+ **New in 1.7.5:** `auth login --auto-approve` no longer cuts a browser callback off at the deadline: a callback captured in time (the listener publishes the capture the moment it lands) is redeemed within the redeem's own bound (two sends of the code, each at the redeem timeout, plus a second), since on the zero-click path the redeem is what makes the key exist; a redeem that outlives that bound ends with the ambiguity stated (a key may exist; deactivate it under API → API Keys) as exit **2**, not the timeout's exit 5, since a retry loop on 5 would mint a key per retry; a callback that arrives after Ctrl+C or after a pasted key won is not redeemed at all, a pasted key is not used while a redeem is in flight, and Ctrl+C during a redeem says a key may exist. The login without the flag keeps its deadline exactly as before. The 409 for a repeat redeem names the key's prefix beside its name, since two same-second logins from one host are named alike. The `--auto-approve` prompt says a first-time address goes through onboarding and must run the command again.
746
+
747
+ **New in 1.7.4:** `auth login --auto-approve` is the zero-click browser handoff for a scripted onboarding: the CLI opens the usual `/xmemcli/<challenge>` page with `?auto_approve=1&host=<hostname>`, and once the browser holds a Console session — an existing one, or one created right then through the Console's own email, Google, GitHub or OIDC sign-in, after which `?next=` returns to the same URL (an address with no account yet lands on the Welcome page instead and must re-run the login once onboarded) — the Console obtains a code carrying an intent to mint a key named `CLI auto-approved login (<hostname>, <timestamp>)` (the email path's shape, with the method named) and navigates the tab to the loopback callback itself; nothing on that surface is clickable, and the delivery works on Safari as well as Chrome and Firefox because it is a top-level navigation, not a fetch. The listener, the `state` check, the redeem with the verifier and the rc write are the ones every browser login uses; the payload carries `method: pkce` and `auto_approve: true`. The key is created only when the CLI redeems the code with its verifier (`POST /cli/token`), never before, and exactly once per login: a second redeem of the same code is answered **409** naming the key (exit **2** in the CLI, with that message), since a stored key's plaintext cannot be served again — find it under API → API Keys, deactivate it if it is still active, and run the login again. A redeem whose reply is lost after the request was sent is retried once with the same code, which yields the key or that 409; lost twice, the CLI stops with the ambiguity stated (a key may exist; deactivate it under API → API Keys) rather than waiting out its timeout. A failed or abandoned handoff leaves no key behind. The flag is refused beside `--email` (exit **2**). Needs a Console that knows the parameter; an older one runs the manual page for the same URL, which still finishes with a click. The loopback listener's done page is in the Console's style; it still says "Code received" and names the terminal for the result, since it is served before the redeem.
748
+
749
+ **New in 1.7.3:** `changes.relationship_outcomes` reports proposed relationships that are absent when the write completes: `replaced` when the stored endpoints differ from the proposal, or `rejected` when no row for the proposal remains (also a restated link the write removed). `unavailable` means the write already committed in an earlier attempt and its relationship report is unavailable; the write succeeded and no mutations were repeated. Each of these prints as a `note: relationship_outcomes …` line after the changes, naming the relationship and its endpoints; surviving links stay under `created.relations`. One entry is different: an `unavailable` entry with an empty name and no endpoints means the whole changes summary of that earlier attempt is unavailable, object changes and keyless handles included, so empty change blocks do not mean nothing changed. It prints as `note: write report unavailable: the write committed; its complete changes summary is unavailable; no mutations were repeated`. `--json` preserves the server's report verbatim, and older servers that omit the bucket continue to work.
750
+
751
+ **New in 1.7.2:** a `trace ls --search --cursor …` answered without the paging fields exits **3** with `stage: "paging_unsupported"`, where 1.7.1 reported `stage: "request"` and advised retrying the same cursor; the advice now covers a Console that no longer pages too (retry after a short wait, and if it persists restart the walk without `--cursor`). A plain `trace ls --cursor` with more than two parts is a usage error (exit **2**) saying a three-part cursor belongs to `--search`, where 1.7.1 sent everything after the first colon as the trace id. A cursor time past 2299-12-31, the Console's own bound, is a usage error too, where the Console answered a bare 400 (or a plain listing served its first page again). `trace …` wait up to 70 seconds between reads of a Console answer, where they gave up at 30 — enough for one Console query's read queue and deadline; a search that runs several queries in a row can still take longer. A scoped write also reports the stored scoped records no action of it touched under `changes.untouched_scope_records` — usually text that did not name them, such as "delete this record", or only restated their stored values on a write the diff engine ran, which used to return an empty `changes` that read as success. In a terminal each one prints as a `note:` line, next to the other advisory records — `note: untouched_scope_records Task task_id='T-1'` — where 1.7.1 printed it among the change lines. `--json` output is unchanged: the block was already kept verbatim. A server that predates the bucket never sends it, so this works against any server.
752
+
753
+ **New in 1.6.1:** a removal the server could not tie to exactly one stored record is reported under `changes.unmatched_deletes`; nothing was deleted for it. In a terminal each one prints as a `note:` line, next to the other advisory records — `note: unmatched_deletes Task task_id='T-999'` — where 1.6.0 printed it among the change lines. `--json` output is unchanged: the block was already kept verbatim. A server that predates the bucket never sends it, so this works against any server.
754
+
755
+ **New in 1.6.0:** `trace calls` lists the LLM calls of one session, a page at a time, and `trace call` opens one call whole — for super admins, as the Console decides. Both need a Console that serves them; an older one answers **401**, exit **4** (`auth`), for a key that is fine.
756
+
757
+ **New in 1.5.2:** a DEEP write can leave a record out when the model's reply for it did not match the schema or ran past the output limit; the server reports each such record under `changes.skipped_candidates`, and it was not stored. In a terminal each one now prints as a `note:` line, next to `overwritten_field_values` and `merged_field_conflicts` — `note: skipped_candidates Dog nickname='Bolt' error_code='model_output_unusable' reason='…'` — where 1.5.1 printed it among the change lines. `--json` output is unchanged: the block was already kept verbatim. A server that skips nothing sends no such bucket, so this works against any server.
758
+
759
+ **New in 1.5.1:** `write --scope-mode drop` asks for a scoped write that skips and reports changes outside its scope instead of failing, and that creates only the records its `--scope` names — which may not be stored yet. The report is `changes.skipped_out_of_scope`: one terminal line per operation, type and set of fields, carrying `count=N`, the number of records it stands for, and naming a record's key only when the `--scope` named that record. A drop-mode write that skipped everything still returns a `changes` block, holding that report alone. A server that predates drop mode refuses `mode` in the scope (exit **3**), which is a clean failure; a deployment part-way through rolling it out is not, so publish 1.5.1 only after the server side is fully deployed — see [Releasing `xmemcli`](../docs/cli/XMEMCLI-RELEASE.md). `--scope-mode reject` is never sent, so plain scoped writes work against any server.
760
+
761
+ **New in 1.5.1:** `write` and `write-status` keep the server's `changes` block — what the write created, updated (field by field, old → new) and deleted, plus bulk summaries and advisory records — verbatim in `--json` output. In a terminal each change is one indented line: a bulk delete or update reads `deleted 3 × Ticket where status='open'`, one the server refused reads `not deleted Ticket: refused — <reason>`, the advisory `overwritten_field_values` / `merged_field_conflicts` records follow as `note:` lines, and past 50 lines the rest is counted in one closing line (`--json` is never truncated); `write-status` with several ids prints a status line per write, each followed by its changes. `changes` is omitted when the write changed nothing, so that output keeps its earlier shape; a drop-mode write that only skipped is the one write that changed nothing and still carries the block. `write` also takes structured mutations (`--mutations-file`, `--mutations-stdin`, `--delete TYPE:ID`) in place of text; the text positional is now optional, and a write with neither text nor mutations, or whose text is empty or only whitespace, is a usage error (exit **2**, `stage: "usage"`). Before 1.5.1 `write ""` reached the server, whose 400 exited **3** with `stage: "request"`, and whitespace-only text was sent and accepted. A structured write's output carries no `extracted`, since it runs no extraction; on a text write the tags now read `Type(identifier)` (`Person(name='Ada')`), as documented, where they read `?(Type)`. A malformed `--scope` (on `write` and `read`) or `--delete` — now including a key field given twice or an empty value — reports `stage: "usage"`, still exit **2**; `--scope` reported `"request"`. `--mutations-stdin` and `--schema-stdin` refuse a terminal instead of waiting on it, and every input file and piped input is read by one reader and decoded as strict UTF-8, so an input that is not UTF-8 (`--mutations-file`, `--schema-file`, `--migration-plan`, `schema suggestions decide --file`, the `xmd validate` / `convert` / `enhance` schema file, or the same bytes piped to `--mutations-stdin` / `--schema-stdin`) is a usage error at exit **2** instead of a traceback at exit **1** — or, piped, a garbled write at exit **0** — and a leading UTF-8 byte-order mark is accepted. Wherever an HTTP error from the API is reported as `HTTP <status>: …`, a body in the API's error envelope now gives the server's own message (`HTTP 400: <message>`, plus the request `field` when the server names one) instead of the first 200 characters of the raw body.
762
+
763
+ **New in 1.3.0:** exit **8** with `stage: "processing"` for a `write` whose server-side wait window expired while the write kept running. In `--json` mode that document also carries a `write_id` field (omitted when the server sent no handle), so a script polls with it directly instead of parsing the message. Additive — no previously documented mapping changed — but a script that reads “non-zero, and not a code I recognise” as failed-and-retryable will now retry a write that has **not** failed, starting a second one the account pays for. Branch on **8** (or on `stage`) and poll `xmemcli write-status <write_id>` until it settles.
764
+
765
+ **New in 1.2.0:** `instance templates` lists the ready-made instance shapes the service ships, with the prose to choose between them, and `instance create --template <id>` builds one from a pinned schema the server already holds. No schema is generated on that path: no description to compose, no `xmd generate` hop, and the instance comes out identical to the one the console would have made from the same template. (Creating an instance still generates its agent brief afterwards, on this path as on any other.)
766
+
767
+ **New in 1.1.0:** `auth login` records the deployment's own MCP origin in `.xmemrc.json` as `mcp_url` (omitted where the server reports none), and `mcp` both connects there by default and accepts it as the pair its key may reach. Requires 0.0.12 or newer to read such a file — see the note below.
768
+
769
+ **Compatibility change in 0.0.12:** an `.xmemrc.json` carrying a key this release does not recognise is now **accepted**, and the unknown key ignored; `email`, `api_url`, and `api_key` are still required and still have to be strings. Until 0.0.12 the key set was closed, which made adding a field to the file a breaking change for **every** command rather than for the one that would read it — a CLI that cannot load the rc cannot resolve a credential for anything. Nothing writes an extra key yet; this release exists so that a later one can.
770
+
771
+ **Compatibility change in 0.0.13 (single-origin deployments):** an API base URL's path is now **preserved**, never stripped — `--api-url https://host/xmem` builds requests under `/xmem` instead of reducing to the bare origin. Where the base's path is exactly `/xmem`, the CLI infers its single-origin siblings on the same host: `auth login` finds the Console at `/console` without `--console-url`, and `xmemcli mcp` reaches `/mcp` without `--mcp-url` (explicit `--console-url` / `--mcp-url` and `$XMEM_CONSOLE_URL` / `$XMEM_MCP_URL` still win when inference does not apply; on a call that names `--api-url …/xmem`, sibling inference beats `$XMEM_CONSOLE_URL`; the MCP origin guard accepts the `/xmem` ↔ `/mcp` pair as one deployment). Bare origins and every other path shape behave exactly as before. See `cli/`.
772
+
773
+ Two consequences worth knowing. Connect instructions from the API assume **1.0.0** or newer everywhere, and **1.1.1** on a single-origin deployment — earlier releases let `$XMEM_CONSOLE_URL` steer sign-in and token redemption to a foreign Console. On a single-origin deployment they render `auth login --api-url https://host/xmem` with **no** `--console-url`. The inference is keyed on the conventional mounts (`/xmem`, `/console`, `/mcp`). A deployment that mounts its services under other prefixes is not inferable: name `--console-url` and `--mcp-url` explicitly there, which is what its own rendered commands do.
774
+
775
+ **Console instance URLs (1.0.0+):** `xmemcli auth login https://host/console/instance/<uuid>` infers the API base (`/xmem`) and Console (`/console`) from that page URL on a single-origin host — no extra flags or env vars. The Connect tab paste prompt uses this form. When no ``.xmemrc.json`` exists yet, the CLI stages the inferred ``api_url`` into a placeholder rc as soon as login starts; an existing credential file is never rewritten mid-login. Remote plain HTTP is refused (HTTPS or loopback only) — the same rule as the plain URLs below.
776
+
777
+ **Plain single-origin login URLs (1.1.1+):** `xmemcli auth login https://host/console` or `…/xmem` (trailing slash optional) infer the same sibling pair with no instance id — useful when signing in before you have an instance page open. Remote plain HTTP is refused (HTTPS or loopback only). An existing `.xmemrc.json` credential is never rebound mid-login; only a missing rc gets a placeholder `api_url`.
778
+
779
+ **Compatibility change in 1.0.0:** console instance page URLs for login (above), immediate ``api_url`` staging in ``.xmemrc.json``, and **1.0.0** as the minimum version named by connect setup instructions. **Revised in 1.1.1:** staging writes only a placeholder when no rc exists (an existing credential file is left alone until login succeeds); single-origin connect instructions name **1.1.1** as their minimum — earlier releases let `$XMEM_CONSOLE_URL` steer the sign-in Console; and HTTPS-or-loopback now applies to every single-origin login URL, including instance pages, not only the new `/console` and `/xmem` forms. A mistaken `--api-url http://host/xmem` is refused the same way.
780
+
781
+ **Compatibility change in 1.7.1:** a bare `auth login` (no `--api-url`, `$XMEM_API_URL` or positional URL) now signs in to the API origin recorded in the nearest `.xmemrc.json` — found by walking up from `--rc-dir`, `$XMEM_RC_DIR` or the working directory, ending at `~/.xmemrc.json`. 1.7.0 ignored that file: it signed in to production and wrote the new, production-bound key into an rc in the working directory or `--rc-dir`. `auth login --email` already followed the rc, but refused a stg or pp origin as having no known Console. A home rc bound to stg, pp or a local stack therefore makes a fresh project's login mint a key there; pass `--api-url https://api.xmemory.ai` to sign in to production instead. An rc origin with no known Console (one that is not production, stg, pp, a `/xmem` base or a local `:8000`), or one that is not a usable http(s) URL, is now refused (exit **2**, naming the file) where it used to sign in to production. Both login paths print the rc-chosen origin and the file before anything is sent (not for the production default). The Consoles of `https://api.stg.xmemory.ai` and `https://api.pp.xmemory.ai` are inferred for `auth login` and `trace`. Their MCP origins, and dk's (`https://dk-mcp.xmemory.ai`), are inferred for `mcp` when no MCP origin is named or recorded in the rc — including for a key named with `--api-key` and `--api-url`, which 1.7.0 sent to the production MCP. The pairing check is stricter everywhere it runs: a stg or pp Console with any API but its own origin, or the reverse, is refused whatever path, query or userinfo either half carries, unless the two are a single-origin `/console` beside its own `/xmem`; so is a percent sign, backslash or non-ASCII character in either half's host or userinfo. `auth login`, `trace` and `auth status` exit **2** on it; the `org` permission probe declines to send the key and reports the original 403. When the API comes from the rc: an origin that is neither https nor loopback (`localhost`, `127.0.0.1`, `[::1]`) is refused (naming a bare origin with `--api-url` beside an https `--console-url` accepts it), and if that API names its own Console (a `/xmem` base its `/console`, a local `:8000` its `:8002`, stg / pp their table Console), a named Console must be that one, in any spelling of that origin (case, a default port, trailing dots on a named host, `localhost` / `127.0.0.1` / `[::1]` for a loopback one) but on the same mount — any other is refused (exit **2**), so a `$XMEM_CONSOLE_URL` exported for one deployment cannot mint a key into the rc of a deployment whose Console is known. Naming the API on the call makes the pair the caller's: `--api-url` with the Console in `--console-url`, or `$XMEM_API_URL` with it in `--console-url` or `$XMEM_CONSOLE_URL`. `auth status`, `trace` and the `org` permission probe hold a named Console to the same rule while the call names no API: the first two exit **2**, saying how to keep that Console, and the probe declines to send the key and reports the original 403. There, naming the API on the call (`--api-url` or `$XMEM_API_URL`, which must match the rc's origin the key is bound to) lifts the rule, and the Console still comes from `--console-url` (`trace`) or `$XMEM_CONSOLE_URL` — unlike login, `--api-url` does not turn `$XMEM_CONSOLE_URL` off for these. Explicit origins are held to the same shape: an `--api-url` / `$XMEM_API_URL`, or the API of a positional single-origin URL, with userinfo, a query, a fragment, a non-http(s) scheme, a percent-encoded or non-ASCII host (spell an internationalised host as `xn--…`), or a host that is neither dot-separated labels of letters, digits, `-` and `_` nor a bracketed IP address, is refused (exit **2**) even when no Console is named, where 1.7.0 recorded it. A recorded `api_url` keeps an IPv6 literal's brackets, and the host as it was written (a trailing dot is recorded, and compared as the same host); 1.7.0 and 1.7.1 read each other's files. `auth status` refuses an `api_url` it cannot parse (exit **2**, naming the rc it came from) where 1.7.0 crashed, exited 3 or, for one such as `http://::1:8000`, went on to use it and exited 0. `auth login` refuses (exit **2**), before it writes or mints anything, an rc directory (`--rc-dir`, `$XMEM_RC_DIR` or the working directory) that is unwritable or unreadable; one whose nearest existing parent is a file, a broken symlink, a symlink loop or unwritable; and a working directory that no longer exists when the rc directory is relative to it. 1.7.0 minted the key and then could not save it (or, on Python 3.12, crashed on a loop). A login that saves nothing, answered by a matching `--api-key` or rc, is not refused. A leading `~` in `--rc-dir`, `$XMEM_RC_DIR`, `--binding-dir` or `$XMEM_BINDING_DIR` is now the home directory for every command, where 1.7.0 read and wrote a directory literally named `~` when the shell left it unexpanded (`--rc-dir=~/x`). If such a directory exists (`./~/x`), it is still the one used, so a credential or binding 1.7.0 wrote there keeps being read, and `auth logout` removes that one; move its files to the home path and delete it to switch. The browser login now moves a malformed rc it replaces aside to `.xmemrc.json.invalid` (mode 0600), as the email login already did, where 1.7.0 overwrote it. Both paths warn about the malformed file before signing in, and refuse (exit **2**) before minting when every backup name beside it is taken; if the file still cannot be moved after the mint, the login exits **3**, naming the new key's prefix, and overwrites nothing. On 1.7.0 and older, signing in to pp or stg needs `--api-url` **and** `--console-url` together: a browser login from an rc bound to pp with `--console-url` alone is refused (`--email` accepted it), and with no flags the login goes to production.
782
+
783
+ See also [`ACCOUNTS_AUTH.md`](../docs/auth/ACCOUNTS_AUTH.md) for the server-side 401/403/404 rules.
784
+
785
+ ---
786
+
787
+ ## Help
788
+
789
+ `xmemcli` supports root `-h` / `--help` (same overview as bare `xmemcli`) and the **`help`** subcommand for nested topics.
790
+
791
+ | Invocation | What you get |
792
+ |------------|--------------|
793
+ | **`xmemcli`** | Full command overview (grouped list of every capability) |
794
+ | **`xmemcli help`** | Same as bare `xmemcli` |
795
+ | **`xmemcli help options`** | Global flags (`--api-url`, `--api-key`, `--json`, …) |
796
+ | **`xmemcli help <topic>`** | Usage, options, and subcommands for that topic |
797
+ | **`xmemcli <cmd> … -h`** / **`--help`** | Same as `help <topic>` for that command path |
798
+
799
+ Unknown help topics exit **2** (same as other usage errors).
800
+
801
+ **Every command in this document has help.** Examples:
802
+
803
+ ```text
804
+ xmemcli help write
805
+ xmemcli help write-status
806
+ xmemcli help read
807
+ xmemcli help org
808
+ xmemcli help org list instances
809
+ xmemcli help instance create
810
+ xmemcli help schema
811
+ xmemcli help schema get
812
+ xmemcli help schema update
813
+ xmemcli help schema dry-run
814
+ xmemcli help schema migrations
815
+ xmemcli help schema migrations list
816
+ xmemcli help schema migrations get
817
+ xmemcli help schema suggestions
818
+ xmemcli help schema suggestions review
819
+ xmemcli help schema suggestions decide
820
+ xmemcli help schema suggestions apply
821
+ xmemcli help xmd
822
+ xmemcli help xmd generate
823
+ xmemcli help xmd enhance
824
+ xmemcli help xmd validate
825
+ xmemcli help xmd validate-yaml
826
+ xmemcli help xmd validate-json
827
+ xmemcli help xmd convert
828
+ xmemcli help trace
829
+ xmemcli help trace ls
830
+ xmemcli help trace get
831
+ xmemcli help auth login
832
+ xmemcli help quota
833
+ xmemcli help options
834
+ ```
835
+
836
+ Nested topics work at any level that exists in the parser (`help org`, `help org list`, `help org list keys`, …). Unknown topics print a short error and suggest `xmemcli` or `help options`.
837
+
838
+ ---
839
+
840
+ ## What `xmemcli` does **not** do
841
+
842
+ | Area | Use the Console instead |
843
+ |------|-------------------------|
844
+ | **Quota increases** | No `quota request-increase`. Observe with `xmemcli quota`; **raise limits in Billing / Quotas UX**. |
845
+ | **Routine API key creation** | Post-onboarding: API Keys UI (CLI opens that flow via `/xmemcli`). |
846
+ | **Billing & plans** | Console only |
847
+ | **Users, roles, org admin** | Console only |
848
+ | **Instance chat** | Console only (trace inspection is `xmemcli trace …`, see [Trace](#trace--the-consoles-operation-traces)) |
849
+ | **Cluster create/delete** | CLI lists clusters; provisioning in Console |
850
+ | **`read --extraction deep`** | Server has no read-depth knob today |
851
+ | **Raw REST proxy** | Curated response fields only |
852
+ | **Instance deletion** | **Human operators only** — a hidden `instance delete` exists for interactive TTY use (type `yes` to confirm). It is omitted from help and must not be used by agents or automation. Console UI remains the primary human path. |
853
+
854
+ On HTTP **402** (quota exhausted), stop and ask a human to extend quota in the Console.
855
+
856
+ ---
857
+
858
+ ## Global flags & environment
859
+
860
+ These apply **before** any subcommand (`xmemcli help options`).
861
+
862
+ | Flag | Env var | Purpose |
863
+ |------|---------|---------|
864
+ | `--api-url` | `XMEM_API_URL` | API origin (default `https://api.xmemory.ai`) |
865
+ | `--api-key` | `XMEM_API_KEY` | Bearer API key |
866
+ | `--instance-id` | `XMEM_INSTANCE_ID` | Default instance for read/write and cluster derivation |
867
+
868
+ | `--rc-dir` | `XMEM_RC_DIR` | Where `auth login` writes `.xmemrc.json`; reads walk **up** toward `$HOME` |
869
+ | `--binding-dir` | `XMEM_BINDING_DIR` | Where `binding` commands look for `.xmemory.json`; reads walk **up** toward `$HOME` and **merge** every scope |
870
+ | `--console-url` (accepted by `trace …` and `auth login`; `$XMEM_CONSOLE_URL` is read by `auth status` too) | `XMEM_CONSOLE_URL` | Console origin serving the trace views (inferred for the public, stg, pp and local API pairs and the `/console` sibling of a `/xmem` base; required for any other API origin). A named Console that provably does not pair with the API origin is refused; the refusal names the setting that supplied it, since `auth status` does not accept the flag |
871
+ | `--json` | — | Force one JSON document on stdout |
872
+ | `--verbose` | — | Add `trace_id` / `console_url` when present (never SQL, diff plans, or internals) |
873
+
874
+ **Credential resolution:** `--api-key` → `$XMEM_API_KEY` → nearest `.xmemrc.json` → *Not logged in — run `xmemcli auth login` first.*
875
+
876
+ **`.xmemrc.json` shape:** `{email, api_url, api_key}`, all three required and all three strings, plus an optional `mcp_url` recorded by `auth login`. Keys beyond those are ignored rather than rejected (see the 0.0.12 note above), so a file written by a newer CLI still loads here.
877
+
878
+ **`context` is the one exception, and it is a security boundary.** Every other command walks up
879
+ from the current directory for the nearest `.xmemrc.json`, which is deliberate: a person chose
880
+ that directory and ran the command in it. `context` has neither — a session-start hook runs it
881
+ unattended, in whatever tree the editor happened to open — so it resolves the credential from
882
+ **`$HOME` only**, and consults a project `.xmemrc.json` only when the caller named that source
883
+ explicitly with `--api-key` / `$XMEM_API_KEY` or `--rc-dir` / `$XMEM_RC_DIR`. Without the pin, a
884
+ cloned repository could ship a key *and* an origin and have both used automatically. This is a
885
+ rule rather than a detector: earlier versions tried to *detect* a checkout-supplied credential
886
+ and each delivery mechanism turned out to be a fresh bypass.
887
+
888
+ **Origin resolution:** `--api-url` → `$XMEM_API_URL` → rc `api_url` → default — **with one escape hatch**: when the key comes from `--api-key` / `$XMEM_API_KEY` (not from the rc), the rc's `api_url` is **not** consulted and the origin falls back to the built-in default (`https://api.xmemory.ai`) unless you also set `--api-url` / `$XMEM_API_URL`. That keeps an explicitly supplied key from being silently paired with an unrelated origin in a project `.xmemrc.json`. If `$XMEM_API_KEY` is set without `$XMEM_API_URL`, the CLI warns once per invocation.
889
+
890
+ **MCP origin resolution (`mcp` only):** `--mcp-url` → `$XMEM_MCP_URL` → the rc's `mcp_url` → the API base's `/mcp` sibling (single-origin) or its hosted pair (stg, pp, dk) → default (`https://mcp.xmemory.ai`). The rc rung follows the same escape hatch as the API origin: a key named with `--api-key` / `$XMEM_API_KEY` never inherits an origin from a project file.
891
+
892
+ **Request identification (new in 1.3.0):** every outbound request carries `X-Xmemory-Client: xmemcli/<version> (python <version>; <system>-<machine>)`, e.g. `xmemcli/1.3.0 (python 3.12.4; Linux-x86_64)`. Each field is reduced to letters, digits, `.`, `_` and `-`, so nothing an OS or a patched interpreter reports can forge the note's own punctuation or fail the request when it is sent; a field with nothing usable left reads `unknown`. No hostname is included. The server uses this header to attribute traffic to a client. The CLI sets no `User-Agent` of its own, then or now: the installed `urllib` opener supplies its default, so the `User-Agent` the server sees is `Python-urllib/<version>` and the identity comes from the header above. Direct API requests from 1.2.0 and earlier send neither, so they record `client_name: python-urllib` -- indistinguishable from any third-party Python caller. Reading a drop in that bucket as third-party traffic leaving is therefore wrong; it is old CLIs upgrading. Three outcomes are distinct and must not be read as one figure: a recognised header, the `other` bucket a present but unrecognised header lands in, and an absent property, which means neither header named a client. Before 1.3.0 the email sign-in exchange was the only path that named itself, and it sent a bare `xmemcli/<version>` with no platform hint; it now sends the same header and the same value as every other path. What the server records for it still differs, though, and not because of the CLI: the console-facing calls (email sign-in, token redemption, whoami) reach the accounts API through the console, which rebuilds the request on its own HTTP client and forwards only Cookie, Content-Type, Authorization and Host. `X-Xmemory-Client` does not survive that hop, so those land as `console`. The attribution described above is what direct API calls record.
893
+
894
+ **Instance resolution (read/write):** `--instance-id` → `$XMEM_INSTANCE_ID` → if exactly **one** instance is visible, auto-select with a **stderr warning** → else error.
895
+
896
+ **Output:** Pipes and non-TTY stdout → JSON. Interactive TTY → one-line summaries unless `--json`.