Skip to content

Headless CLI Client

engram is one binary that is both the server (engram serve) and a client for that server. The three client verbs — engram search, engram list, and engram store — let anything with a shell talk to a remote engram server over the Connect API, with no MCP client involved: a subagent with a closed tool list, a CI step, or a cron loop.

Get the binary through Install. To register an MCP connection for an agent, follow Agent Setup and its release availability notice. MCP setup uses --url / ENGRAM_URL; the Connect commands below use --server / ENGRAM_SERVER_URL and the credential precedence described here. Setup’s generic-only --token-file behavior does not apply to these Connect commands.

The server must be running with the Connect lane mounted (connect.headless: true, or a UI-enabled deployment) — see Configuration.

Command Purpose
engram search --server <url> --query <text> --scope <scope> Vector search over stored memories
engram list --server <url> --scope <scope> Paged, filtered recall
engram store --server <url> --content <text> --scope <scope> Write a memory (the only write verb)

Run engram <verb> --help for the full flag list of each command — every flag mirrors a field on the corresponding Connect request message.

All three commands accept the same five flags, each resolved flag-then- environment-then-default through the same internal/config registry the server side uses:

Flag Purpose
--server Server base URL. Falls back to ENGRAM_SERVER_URL if unset. Required (from one or the other) — there is no localhost default.
--token-file Path to a file containing the bearer credential. Falls back to ENGRAM_TOKEN (env wins over file if both are set).
--insecure Skip TLS certificate verification. Always prints an unconditional warning to stderr — this cannot be suppressed and has no environment fallback, deliberately: it cannot be silently enabled by an inherited environment variable.
--output Force "json" or "text". Default: JSON when stdout is not a terminal, a human table when it is.
--timeout Bounds the RPC call. Falls back to ENGRAM_TIMEOUT. Default 30s. 0 is rejected as a usage error — it does not mean unbounded. See Request timeout below.

Credential precedence, in order: ENGRAM_TOKEN environment variable, then the file named by --token-file. There is no --token flag. This is deliberate: a credential must never be able to reach argv, ps output, or shell history. Omitting both is legal — an anonymous call against a no-issuer server is a normal request, not an error.

engram search and engram list each require exactly one of two flags — there is no default that silently picks one for you:

Flag Purpose
--scope Limit recall to one scope; scope is required unless cross_spine is true; omit and pass --cross-spine to span every scope you can read; mutually exclusive with --cross-spine.
--cross-spine Span every scope you can read; mutually exclusive with --scope.

Passing neither, or passing both, is rejected by the CLI itself — before any network call — with exit 2. The server would in fact accept --scope together with cross_spine=true, silently discarding the scope and logging the discard at Info on the server side, where the calling agent never sees it. The CLI is deliberately stricter than the server on this one combination: an explicitly-typed filter being discarded without the caller learning about it is exactly the kind of surprise this interface is designed to avoid, so the client rejects the pair outright rather than forwarding it.

engram list supports two independent paging styles — offset-for-UI and cursor paging — never combined:

Flag Purpose
--limit Max results per page; 0 resolves to the maximum, 1000; a value above 1000 is rejected.
--offset Offset-for-UI paging; cursor_mode, offset, and page_token are mutually exclusive.
--cursor-mode Opt into cursor paging on the first (tokenless) page; mutually exclusive with --offset and --page-token.
--page-token Opaque cursor from a previous response’s next_page_token; mutually exclusive with --offset and --cursor-mode.

Passing more than one of the three paging-mode flags is rejected by the CLI itself — before any network call — with exit 2, via a declared cobra flag group (the same mutual-exclusion enforcement mechanism as --scope/--cross-spine above). engram search --k carries the same contract as --limit above except for its own zero-value default: 0 resolves to 20, and a value above 1000 (the shared maximum) is rejected. Either rejection surfaces as exit 2 (see Exit codes).

Data goes to stdout as one JSON object per invocation, mirroring the Connect response’s own field names (memories, total, next_page_token, id, short_id, …). Every diagnostic — warnings, the --insecure notice, errors — goes to stderr, so engram search ... | jq is always safe. An empty result set is a success: engram search and engram list exit 0 with "memories":[], never null.

On a --cross-spine call, text-mode output for both engram search and engram list appends a coverage footer after the table:

searched_scopes: 3

or, when the server reports the authorized span was truncated:

searched_scopes: 3 scopes_truncated: true

or, when the server’s own coverage-enumeration query failed after hits were already found — the call still succeeds, but there is no count to report:

scopes_unknown: true

The footer reports a count of the scopes searched, never the scope names themselves — except the scopes_unknown form, which carries no count because there is none. It prints only on a --cross-spine call — output for every other invocation is unchanged, byte-for-byte, from before this capability existed. The JSON lane already carried searched_scopes and scopes_truncated on every response before this release, now joined by scopes_unknown, and is unaffected by this change beyond that addition.

Text-mode output for both commands also appends a recall-gate hidden-count footer, after the coverage footer, whenever the response reports hidden records:

recall_gate_hidden: 5 archived: 2 superseded: 2 expired: 1 scheduled: 1

This one prints on any engram search/engram list text call, not only --cross-spine — a scope-confined call can hide records too. It prints nothing when no records were hidden or the count is unavailable, and the JSON lane carries recall_gate_hidden under the same name ({total, archived, superseded, expired, scheduled}), so this addition needs no separate rendering code there.

When the server runs the Jev reranker (ENGRAM_SEARCH_RANKER=jev, see Search reranking (Jev)), engram search text output gains a RELEVANCE column and JSON output gains a per-memory relevance field — the provider’s probability, 0 to 1, that the record answers the query; values all near zero mean nothing returned answers it. Without it, output is unchanged byte for byte. engram list output never carries relevance.

Every operator command — reindex, prune-expired, summarize-missing, backfill-short-ids, migrate (plus its status and revert subcommands; see the Migrate guide), migrate-remap-owner, its deprecated alias migrate-set-owner, setup, and every engram spine-review leaf (currently scan, verify, consolidate, archive, restore, and purge) — also accepts --output:

Value Behavior
json Write exactly one JSON document to stdout.
text Render a one-line prose headline followed by one aligned line per field of the same document json emits. This is a human-readable view, not a stable interface, and is not intended to be parsed.
(absent) Detect from the command’s own configured output writer: a human terminal renders text; anything else (a pipe, a file redirect) renders json.
anything else Rejected as a usage error (exit 2), naming --output and its legal values — the same validator the three client verbs use.

As with the client tier, the JSON document goes to stdout and any warning or diagnostic goes to stderr, so engram <operator-command> --output json | jq . is always safe. A sweep that affected zero records still emits zero-valued counters and [] for any list-shaped field — never null — and exits 0. Both lanes derive from one serialization: the json document is produced by encoding/json over the command’s own report struct, and text is a rendered view of that same value — never a second, independently maintained format. The json document cannot carry a fact text omits, and text cannot show a field json withheld, because both are read from the same value. The json lane is the contract; the text lane may change shape in any release. A preview is always distinguished from an applied mutation by an explicit boolean field plus separate count fields, never by prose alone.

a mutating operator command previews by default and mutates only when apply is set.

This applies to every command routed through the registerDestructive choke point — a wider set than the blast-radius table’s destructive column alone. Today that is every command the table classifies destructive (prune-expired, migrate-remap-owner, spine-review purge, migrate revert), plus migrate, which is classified non-destructive (its sweep is additive-only) yet still previews by default and mutates only under --apply — the same contract, extended to a mutating-but-not-destructive command. migrate status is read-only and carries no --apply flag at all. A bare invocation of any --apply-gated command reports what the sweep would do and exits 0 without touching the collection; add --apply to perform the mutation. A forgotten --apply is therefore a harmless no-op — the command just previews again.

reindex and summarize-missing are classified non-destructive and keep their pre-existing opt-in preview idiom, --dry-run. This is a deliberate two-idiom split, not an accident: on a command routed through registerDestructive (above) a forgotten --apply costs nothing (it just previews again), but on reindex/summarize-missing a forgotten --dry-run merely performs the recoverable, additive thing the operator already asked for.

summarize-missing --scope <scope> (or --all-scopes) sweeps that scope (or every scope) for records with no summary yet; --scope and --all-scopes are mutually exclusive, matching every other operator command that offers this pair.

backfill-short-ids has MOVED from the --dry-run idiom to the --apply one: it is now a thin delegating alias for engram migrate — see the upgrade guide’s ## Unreleased entry on backfill-short-ids --dry-run removal for the full behavior break. Its own blast-radius row is still non-destructive (the sweep is additive-only), exactly like migrate itself above — the boundary between the two idioms is therefore no longer purely the blast-radius table’s Destructive column; it is “does this command route through registerDestructive”, which today is every Destructive:true command plus migrate and backfill-short-ids.

engram spine-review scan --scope <scope> (or --all-scopes) reports an inventory of the memory spine and never mutates on any path. --scope and --all-scopes are mutually exclusive. Alongside the total and a per-scope/per-category breakdown, it reports the health signals an operator needs before deciding what to curate: how many records carry a summary and how many do not, how many are superseded, expired, or archived (three independently observable states, each read from its own field, so a record is never double-counted into the wrong bucket), how many carry citations and how many citations exist in total, and the count of distinct non-empty owners.

That last number is what makes the Subject-less claim observable rather than asserted: scan is built on the operator tier, never on the Subject-gated Search/List, so a sweep that had been silently narrowed to one caller’s bucket would report a single owner even on a collection holding records from several. The report also carries scanned_at — the one instant the sweep took at the top of the run and evaluated expired/scheduled against — so a reader knows exactly what those counts are relative to.

scan counts what recall hides. Superseded and archived records are soft-hidden from search_memory/list_memory but still appear here, which is the point: an inventory that inherited recall’s blind spots could not tell you what needs curating.

engram spine-review verify --scope <scope> (or --all-scopes) classifies every stored citation into one of four tiers: valid, moved (the cited excerpt still exists in the same file, at a different byte offset — ordinary drift from an edit above the cited range, never treated as breakage), broken (the file is missing, or the excerpt is gone from it entirely), and unverifiable (a commit/url/repo citation, an empty cached excerpt, a citation whose owning record names a different repo than the working tree, or a ref this command refuses to read for safety — every such citation carries the reason it was not checked, so a clean report can never be read as coverage the run did not have). --scope and --all-scopes are mutually exclusive. It never reads outside the working tree it was run in — not even through a symlink — and never widens its search past the file a citation names.

fail-on accepts broken, moved, unverifiable, or any; omitted, verify exits 0 regardless of findings

verify exits 0 by default even when it reports broken citations — “the command worked” and “the data is healthy” are different questions. Pass --fail-on <tier> to ask the second one from a CI step without parsing the report: it exits a distinct code (7, see below) when the named tier has at least one entry.

engram spine-review consolidate --scope <scope> (or --all-scopes) reports ranked near-duplicate candidate pairs — (record A, record B, score) rows sorted by score, highest first — using each record’s already-stored vector: no text is re-embedded and no vector ever crosses the wire from engram to Qdrant. Exactly one of --scope or --all-scopes is required: they are mutually exclusive, and supplying neither is rejected with exit status 2, like spine-review scan, spine-review verify and summarize-missing (#508).

The structural ranking never merges, never mutates, and never labels a pair a “duplicate.” It ranks candidates and stops — deciding whether two records are the same fact is a judgment for the operator (or a future semantic skill) to make with the ranked list in hand, not something this structural sweep pre-decides. There is no clustering and no default similarity threshold: --min-score bounds report SIZE, not correctness, and its absence (the default) means no filtering at all — a pair with a negative cosine score is reported just like any other. --top-k bounds how many neighbours are considered per record (a small default; see the flag’s own --help text for the exact number). The optional advisory verdict described below MAY name any relation, including duplicate — that is the verdict object doing its documented job, not the structural ranking pre-labelling a pair.

With --all-scopes, a candidate pair may span two different scopes — two scopes holding the same fact is exactly the duplication an operator wants surfaced, so cross-scope pairs are reported, and each row names both records’ scopes (a_scope/b_scope in JSON) so a within-scope duplicate is never confused with a cross-scope one.

A scope with fewer than two records reports zero candidates and exits 0; the JSON candidates array is [], never null, in that case.

When ENGRAM_DECISIONS_PROVIDER is set (off by default), each candidate pair ALSO gets an advisory relation verdict, surfaced to you and never acted on — consolidate still never merges or mutates a record because of one. --no-verdicts skips this pass entirely for one run: no record content is sent and no verdict key appears anywhere in the report.

Each request carries, per record, up to ENGRAM_DECISIONS_VERDICT_STATE_CHARS characters total of its summary followed by its content (default 1500) — see configure’s “What leaves your deployment” for what that means for your deployment. The decider is asked which of five relations best describes how the newer record relates to the older one; the more recently created record of the pair is always sent as record_b, so updates means the NEWER record updates the older one, regardless of which side of the pair (a/b) it happened to land on in the structural ranking:

Relation Meaning
duplicate Both state the same fact; keeping both is redundant (one may be more complete).
contradicts They make incompatible claims about the same subject; one corrects or reverses the other.
updates The newer record (record_b) is a newer state or a more complete version of the same fact.
related Same subject area, but different and compatible facts; both are worth keeping.
unrelated Different subjects.

A second question asks whether both records are about the same specific subject — same_subject, a probability. A verdict whose own relation probability falls strictly below ENGRAM_DECISIONS_VERDICT_THRESHOLD (default 0.9, or --verdict-threshold to override it for one run) is flagged needs_review.

A successful verdict’s JSON shape:

{
"relation": "duplicate",
"probabilities": {"duplicate": 0.93, "contradicts": 0.01, "updates": 0.02, "related": 0.03, "unrelated": 0.01},
"same_subject": 0.97,
"needs_review": false,
"model": "typesafe/jev-1.13-20260917"
}

A failed request instead reports only its failure class:

{"error": "timeout"}

--output text renders the same object as verdict=duplicate p=0.93 same_subject=0.97 probabilities=duplicate:0.93,contradicts:0.01,updates:0.02,related:0.03,unrelated:0.01 model=typesafe/jev-1.13-20260917, appending [needs review] when flagged, or verdict unavailable (<class>) for a failure — text is a rendered view of the same JSON object (D-05), never a second source of truth.

<class> is one of: auth, bad_request, context_too_large, rate_limited, unavailable, timeout, response_too_large, malformed_response, invalid_request, canceled, error, or state_unavailable (the record’s stored state could not be fetched from engram’s own store, before any request left for the provider). A failed verdict is reported per pair and never changes this command’s exit status — consolidate still exits 0 even when every verdict fails. --timeout bounds the whole sweep, including the verdict pass: a deadline reached mid-pass reports every still-outstanding pair verdict unavailable (timeout) rather than aborting the sweep or dropping candidates.

Before sending anything, consolidate prints one line to stderr naming the decisions provider, model, endpoint host and the per-record character bound; after the pass, one summary line with requested/answered/ needs_review/unavailable counts and, on any failure, a per-class breakdown. Neither line ever reaches stdout — a piped --output json | jq . consumer never sees them, and script-based consumption of the JSON report is unaffected either way.

Reader guidance, not a rule the renderer enforces: a related verdict paired with a high same_subject probability is often worth a second look — a genuine refinement or state change that falls just short of a clean updates verdict tends to land there.

spine-review archive / spine-review restore

Section titled “spine-review archive / spine-review restore”

engram spine-review archive --id <id> [--id <id> ...] explicitly retires one or more records: it stamps archived_at (see Archiving for the field’s full contract), an entirely new key orthogonal to both expiry (not_after) and supersession (superseded_by). engram spine-review restore --id <id> reverses it. Both accept --id more than once, processing every id and reporting a per-id outcome — there is no filter form; a mutating verb’s blast radius is always the explicit id set an operator supplied, never an implicit scope sweep.

Each id resolves to exactly one of three outcomes, reported honestly rather than inferred from an error string:

Outcome Meaning
changed The record’s archived state was mutated (stamped or cleared).
already The record was already in the target state — no write issued.
not_found The id does not resolve to an existing record.

An unknown id reports not_found on both verbs identically — there is no asymmetry where one verb errors and the other silently reports success for the same typo’d id. Archiving an already-archived record, or restoring a never-archived one, is idempotent: it reports already and mutates nothing.

Retained, not deleted. Neither verb removes a point, erases content, or drops a vector. An archived record stays fully fetchable via get_memory (and the MCP tools) — only recall (search_memory/list_memory/ search_discovery/list_scheduled) hides it, exactly like a superseded or expired record. engram spine-review scan reports an archived count as its own bucket, separate from expired — an archived record’s not_after may or may not also be lapsed, but the two states are independently observable and never folded together.

engram spine-review purge deletes purge-eligible records — the phase’s sharpest edge, and the only irreversible command in the spine-review group. It previews by default (see Destructive commands above) and mutates only under --apply.

Eligibility classes (--class, repeatable): superseded (a superseded_by link to an existing record, past a grace window), expired (not_after lapsed, past a grace window), and archived (archived_at past a retention window — 90 days by default, overridable with --older-than). A class-only run needs no --scope: a class is a derivation, not an operator judgment.

The free-form filter path is gated harder:

the free-form filter path requires an explicit --scope or --all-scopes: category or tags always engage it, and older-than engages it when no class is selected.

Note the asymmetry, which is deliberate. --older-than alongside a --class is read as parameterizing that class’s window (overriding archived’s 90-day retention, say), so it does not engage the harder gate. --category and --tags engage it even when a class is also selected, because narrowing by category or tag is a semantic judgment no structural class expresses — and D-10 gates on how much judgment the operator supplied, not on how many records the run would touch. A --class superseded --category decision run is therefore narrower than --class superseded alone yet still requires an explicit scope.

The extract-before-delete gate (rule 7smp8vy9hr). Every candidate must satisfy one of two paths before it can be deleted: a server-set superseded_by link naming a later record (unforgeable — this is the identical field Store.Supersede writes and Update preserves, never a caller-supplied tag), or an authoritative milestone-summary record covering the batch. The per-record path and the batch floor carry different strength: the per-record link cannot be satisfied by anything a caller writes, while the batch floor’s marker is a caller-mintable tag convention over a real, server-timestamped record — strictly stronger than no artifact at all, strictly weaker than the per-record link. Neither is ever presented as proof.

Preview and apply happen within ONE invocation. purge carries its preview into --apply through an in-process manifest — there is no --manifest/--token flag, and a preview cannot be carried into a later invocation. --apply re-derives eligibility within its own run and deletes only the intersection of what it just showed and what is still eligible at that moment; a record that became ineligible since preview is spared, and a record that became newly eligible is reported appeared (never deleted — re-run to include it). Because the two derivations run milliseconds apart in one process, the intersection guards against a concurrent writer, not against operator delay — the extract gate, the mandatory --scope on the filter path, and the discovery/rule category exclusions carry the real safety weight. Cross-invocation preview is a possible future ADDITIVE change requiring a signature; it is not shipped here.

The CLI uses the following exit-code meanings. Codes 8 and 9 belong to setup, available from v0.16.0. See Agent Setup for availability and result handling. Code 10 comes from a client verb whose server response overflowed what one response can carry — in practice list and search — or from an operator command whose own Qdrant read overflows (Phase 5 of this milestone bounds those sweeps).

Code Meaning
0 Success (including an empty result set)
1 Unclassified internal error — a backstop, not a general-purpose failure code. Reached by exactly two paths (see caution below).
2 Usage or validation error — a bad flag value, a violated mutually-exclusive flag group, or engram’s own semantic validation (a missing --server/ENGRAM_SERVER_URL, an invalid --output value, an empty required flag)
3 Authentication or authorization failure
4 Not found
5 Transport or server unavailable
6 Request deadline exceeded — the server accepted the request but did not answer within --timeout
7 Findings reported under an explicit opt-in flag (e.g. spine-review verify --fail-on) — the command itself succeeded; the data just didn’t pass the check
8 Setup partially failed: some attempted runtimes succeeded and some failed
9 All attempted setup runtimes failed
10 Response too large — the server’s result exceeded what one response can carry (Connect resource_exhausted, hint response_too_large); retry with a smaller --limit or --k, or without --full; retrying the same request fails the same way.

Absent runtimes are skipped and do not count as failed setup attempts.

Every client verb (search, list, store, get) bounds its RPC call with --timeout (or ENGRAM_TIMEOUT), default 30s. 0, a negative value, and a malformed duration are all rejected as usage errors (exit 2) before any dial — --timeout 0 together with an unreachable --server still exits 2, not 5. A server that accepts the connection but never answers within the window reports exit 6, distinct from exit 5 (the server refused the connection, or was never reachable at all).

The operator commands’ own --timeout is a different flag with different zero-semantics, and it is not uniform across commands:

Commands --timeout meaning 0 behavior
search, list, store, get, migration-status Per-RPC-call deadline Rejected (usage error)
reindex, prune-expired, summarize-missing, backfill-short-ids, spine-review scan, spine-review verify, spine-review consolidate, spine-review archive, spine-review restore, spine-review purge, migrate, migrate status, migrate revert Whole-sweep wall-clock budget Disables the deadline (unbounded), unchanged
migrate-remap-owner, migrate-set-owner Whole-sweep wall-clock budget Rejected (usage error) — changed this release, see the upgrade guide

A reader comparing engram search --help against engram reindex --help and engram migrate-remap-owner --help side by side should not have to infer this table from the flag’s one-line usage text — the three groups genuinely disagree on what --timeout 0 does.

migrate --apply and backfill-short-ids --apply budget TWO full-backlog passes under one --timeout: a fresh DryRun preview (which additionally performs a MintShortID collision-probe Count per eligible record), then the manifest-limited apply pass — with no way to allocate time between the two. Size --timeout for both passes together, not just the write you expect.

migrate revert --apply budgets an even larger share under one --timeout: the CLI’s own whole-range preflight, Store.Revert’s independent SECOND whole-range preflight (repeating the identical exhaustive scan before it is allowed to write anything), and then the write-convergence loop itself, which re-derives and re-scrolls on every pass until the backlog reaches zero. An operator sizing --timeout for “one reverse walk of my backlog” is actually budgeting for at least two full read-only passes over that same range before any inverse is even applied.

Run engram with no arguments. It writes one JSON document to stdout and exits 0: every command in the live binary, every flag with its type, default, and usage, and this same exit-code table — derived from the running binary’s actual command tree and exit-code constants, not maintained by hand. engram --help is unaffected and still prints ordinary human help.

This catalog is the machine-readable, authoritative form of everything on this page. When the two disagree, trust the binary — this page is prose describing it, not the contract itself.

Terminal window
engram | jq '.commands[] | select(.name == "search")'

Every command in the catalog carries a blast_radius object:

"blast_radius": {
"read_only": true,
"destructive": false,
"idempotent": true,
"open_world": false
}

This is the same taxonomy the MCP tool annotations publish — readOnlyHint, destructiveHint, idempotentHint, and openWorldHint — read from the same table, so an agent that has already learned to branch on one lane’s hints applies the identical logic on the other. The JSON keys are snake_case rather than the MCP wire’s camelCase, so the two forms rhyme without being byte-identical.

Each hint takes the conservative stance: a value is true only if it holds under every valid invocation of that command, not merely the common case. migrate-remap-owner, for example, is "destructive": true because its --from <value> form can overwrite an existing, non-empty owner — even though its --from-missing form only ever fills an empty one.

This classification is a discoverability aid, not an authorization mechanism — it tells an agent what to expect, it does not gate what the agent is allowed to run.

  • Configuration — connect.headless and the other server-side settings the Connect lane depends on.