Skip to content

hadron CLI

Command surface and stable contracts of the hadron command-line interface. Installation and sign-in live in Install the hadron CLI. The CLI also documents itself: hadron agentic-usage prints the complete agent-facing contract, and hadron <command> --help covers every flag.

Source: hadron-memory/hadron-cli.

Command surface (v1)

hadron auth login | logout | whoami | status | token create|ls|validate | token revoke <id>
hadron memory ls [--shared-with-me] | get <id-or-urn> | set [<id-or-urn>] [--org <ref> | --owner-me | --app <ref> --agent <ref>] [--class <c>] [--max-rev-count <n>] [--schema <json> | --schema-file <path>] | attach <memory> --app <ref> --agent <ref> | set-active <id-or-urn> | rm <id-or-urn> | clone <id-or-urn> --target-urn <org:slug> | extract (<parentRef> | <loc> -m <memory>) <targetUrn> [--move] | export <id-or-urn> [--out <dir>] | validate <memoryRef> [--check <kind>]… [--limit N] [--fail-on-findings] | encrypt <memory> --data-key - | link-user <memoryRef> --external-user <id> [--data-key -] --yes
hadron memory member ls <memory> | member add|set-role <memory> --user <id> --role <r> | member rm <memory> --user <id>
hadron memory share ls <memory> | share create|set-role <memory> --grantee <user-ref> --role <r> | share rm|revoke <memory> [--grantee <user-ref>] [--yes]
hadron memory subscription ls <memory> | subscription create|set-role <memory> --org <id> --role <r> | subscription rm <memory> --org <id>
hadron access check <user> <resource>      # effective access for one (user, resource) pair, with the grants that confer it
hadron node ls [-m <memory>] | get <node-urn> | add | update <node-urn> | move <node-urn> (--to-urn <urn> | --to-memory <memory>) | clone <node-urn> (--to-urn <urn> | --to-memory <memory>) | merge <node-urn> --into <urn> [--field <f>]… [--delete-source] --yes | rm <node-urn> [--hard] | export <node-urn> [-o <file>] [--format md|json] | import [<file>|- | --url <url>] [-m <memory>] [--loc <loc>] [--as-content] [--with-edges] [--task <ref> [--task-args <json>] [--app <ref>]]
hadron node revision list <node-ref> [-m <memory>] [--limit N] | revision get <revision-id> | revision restore <revision-id> [--truncate [--yes]] | revision label <revision-id> --label <text> | revision delete <revision-id> [--yes] | revision clear <node-ref> [-m <memory>] [--yes]
hadron edge ls <node-urn> | add | update <edge-id> | rm <edge-id>
hadron object create -m <memory> --type <t> (--fields '<json>' | --fields-file <path>) [--key <seg>] [--name <n>] | get <ref> | update <ref> (--fields '<json>' | --fields-file <path>) [--reason <text>] | find -m <memory> --type <t> [--match '<json>'] [--where '<json>'] [--sort '<json>'] [--limit N] [--offset N] | delete <ref> [--hard] [--yes]
hadron asset ls -m <memory> [--mine] [--mime <type>] [--include-deleted] [--limit N] [--offset N] | get <asset-ref> [-o <path>|-] [--force] | url <asset-ref> [-m <memory>] | upload <file> -m <memory> [--mime <t>] [--name <n>] [--description <d>] | rm <asset-ref> [--yes] | restore <asset-ref> | link <asset-ref> --node <new-node-urn> [--name <n>] [--description <d>]
hadron search <query> [-m <memory>]… [--mode hybrid|keyword|vector|regex] [--prefix <loc>] [--type <t>] [--tag <t>]… [--limit N] [--offset N] [-l]
hadron replace text <old> <new> --field <f>… (--node <node-urn> | -m <memory>)… [--prefix <loc>] [--regex] [--ignore-case] [--max-nodes N] [--reason <text>] [--dry-run] [--yes]
hadron task run (<node-urn> | <loc> -m <memory>) [--arg k=v]… [--app <ref> [--as-self]]
hadron chat read [--since <seq>] [--node <urn> | -m <memory> --messages-loc <prefix>] | post (--body <text|-> | --body-file <path>) [--node <urn>] [--handle <h>] [--identity <i>] [--role <r>] [--reply-to <loc>]
hadron team init -m <team-memory>
hadron team persona create --role <role> [--name <n>] [--team-agent <ref>] (uses --app) | list [--org <ref>] [--role <r>] | get <name-or-ref> | retire <name-or-ref> --yes
hadron team session start --as <persona> [-m <team-memory>] [--repo <r>] [--branch <b>] [--transcript <path>] [--host <h>] [--tool <t>] [--model <m>] [--force] | whoami | log (--pr | --issue | --commit | --branch) <ref> [--action <a>] [--detail <json>] [-m <team-memory>] | end [--summary <text>] [--session <id>] | list [--active] [--as <persona>] [--repo <r>] [--limit N] [--offset N] | list (--pr | --issue | --commit | --branch) <ref> [-m <team-memory>]
hadron team chat post <body|-> [--reply-to <seq>] [--as-me] (uses --app or the binding) | read [--since <seq>] [--mentions-me | --mentions <ref>] (uses --app or the binding)
hadron spec ls [-m <memory>] | get <citation>|--prefix <p> | describe | use [<memory>] | register [--check] | find <query> [--match-exactly] | grep <pattern> [--regex] [-i] [--field content|abstract] [--prefix <loc>] | replace <pattern> <replacement> [--regex] [--word-boundary=false] [--dry-run] [--yes] [--max-specs N] | new … | edit <citation> | extract <citation> --to-feature <fff> | link <from> <to> | lint [<citation>] | check-tools [--prefix <loc>] | citations [--src <path>]… [--exclude <glob>]… [--loose] [--stale-abstracts] [--strict] | supersede <citation> | import spec-kit|code
hadron coding review list -m <memory> [--root <loc>] [--broken] | review create <check-name> -m <memory> --trigger <cond> --description <d> [--scope <s>] [--tag <t>]… [--link <ref>[=<label>]]… [--seq N] [--content <text|-> | --content-file <path>] | review lint -m <memory> [--root <loc>] [--toolchain <t>|-] [--strict] [--suggest] [--fix [--yes]] | preflight list -m <memory> [--root <loc>] [--broken] | preflight create <loc> -m <memory> --route <action> --description <d> [--name <n>] [--symptom <s>] [--section <heading>] [--type <t>] [--tag <t>]… [--link <ref>[=<label>]]… [--seq N] [--content <text|-> | --content-file <path>] [--no-back-edge] [--no-body-line] [--dry-run] | preflight route <node-ref> -m <memory> --route <action> [--description <d>] [--symptom <s>] [--section <heading>] [--no-back-edge] [--no-body-line] [--dry-run] | preflight lint -m <memory> [--root <loc>] [--strict]
hadron agent ls [--org <id>] [--type ASSISTANT|CHATBOT] [--visibility ORGANIZATION|PERSONAL|PUBLIC] | ls --public [--type <t>] [--limit N] [--offset N] | get <ref> | create --org <id> --name <n> [<field flags>] | update <id> [<field flags>] | rm <id> --yes
hadron run trigger --app <ref> --entry <node-urn> [--arg k=v]… [--as-self] [--ai-config <name>] [--wait [--wait-timeout <dur>]] | get <id> | list [--app <ref> | --org <ref>] [--status <s>] | cancel <id> [--yes]
hadron schedule create --app <ref> --name <n> --cron '<expr>' --entry <node-urn> [--tz <IANA>] [--arg k=v]… [--as-self] [--policy <json>] [--disabled] | list [--app <ref>] | update <id> [--cron …] [--entry …] [--enabled=false] | rm <id> [--yes]
hadron webhook create --app <ref> --name <n> --entry <node-urn> [--args-schema <json>] [--as-self] [--policy <json>] [--disabled] | list [--app <ref>] | rm <id> [--yes] | rotate <id> [--yes]
hadron ticket mint --org <ref> --action comm.outbound --count <n> [--app <id>] [--note <text>] [--expires <iso8601>] | list --org <ref>
hadron grant create --org <ref> --user <ref> --action <a>[,…] [--expires <iso8601>] | ls [--org <ref>] [--user <ref>] | revoke <id> --yes
hadron app ls --org <org> | install | uninstall <id> | use <urn>
hadron ai-config ls [--app <id-or-urn>] [--agent <id-or-urn>] | create ((--app|--agent|--org <id-or-urn>) --name <n> --provider <p> --model <m> [--api-key -] | --file <path>) | update <id> … | rm <id>
hadron mcp-server ls [--org <ref>] | get <id> | tools <id> | create --org <ref> --slug <s> --name <n> --url <u> [--header 'Name: value']… [--allow <tool>]… [--disabled] | update <id> [--name <n>] [--url <u>] [--header …]… [--clear-headers] [--allow <tool>]… [--clear-allow] [--enabled|--disabled] | rm <id> --yes
hadron connection grant create --connection <ref> --app <ref> --scopes <s>[,…] [--expires-at <iso>] | grant ls [--connection <ref>] | grant revoke <grant-id> --yes
hadron secret create --name <n> --scope user|org|app|memory [--owner <ref>] --kind generic|webfetch-auth [--value-file -|@file] | ls --scope <s> [--owner <ref>] | rm <id> --yes
hadron org create --name <n> --urn <urn> | get <id> | public <org-ref> | update <id> | rm <id> | member ls <org-id> | member add|set-role <org-id> --user <id> --role <r> | member rm <org-id> --user <id> | invite create <email> --org <id> --role <r> | invite accept <slug> | invite show <slug>
hadron user search [query] [--limit N] [--offset N] | set-roles <userRef> --role <r>… --yes | merge <source> --into <target> --yes
hadron profile set [--name <n>] [--email <e>] [--handle <h>]
hadron config get | set | list
hadron api <query-or-mutation>      # raw GraphQL escape hatch
hadron server-info                  # the server's version + capability surface
hadron version
hadron completion <shell>
hadron agentic-usage                # prints the agent contract

Global flags

Flag Effect
--json Machine-readable output on stdout; progress and errors on stderr (see --json on the failure path). Field names are stable: new fields may be added, existing ones are never renamed or removed without a major version bump.
--server <url> Hadron server base URL for this invocation (default https://srv.hadronmemory.com; persist with hadron config set server).
--app <urn> App URN context for this invocation (persist with hadron app use). Most calls need none.

With --json, errors are emitted on stderr as {"error":{"code":<exit-code>,"message":"..."}} — with one exception, and a consequence for stdout that scripts need to know about; both are covered under --json on the failure path.

Exit codes (stable contract)

Code Meaning
0 success
1 generic failure
2 usage error (bad flags/arguments, missing --yes)
3 authentication required or rejected
4 not found (or not visible to this principal)
5 conflict or validation failure (e.g. duplicate install; spec lint / spec check-tools / register --check findings)
6 cancelled / timed out waiting for the user

Scripts and agents should branch on exit codes, not parse error text.

--json on the failure path

Branching on the exit code matters more than it looks, because --json does not guarantee that stdout holds JSON when a command fails.

Situation Exit stdout stderr
Success 0 JSON payload progress / notes, if the command emits any
A single-entity read fails (node get <missing>, memory get <missing>, spec get <missing>) 4 empty {"error":{...}}
Bad input (node get 'not-a-urn') 2 empty {"error":{...}}
Server or transport failure (the server is down, a gateway returns 5xx) 1 empty {"error":{...}}
A batched read has some unavailable refs (node get <a> <b> …) 4 JSON payload, with the misses listed under unavailable progress / notes, if any
A list read finds nothing, including in a memory that doesn't exist or isn't visible 0 [] progress / notes, if any
An unknown flag (--nosuchflag) 2 empty plain text, not JSON

Four practical consequences:

  • Parse the exit code before parsing stdout. A script that pipes stdout straight into a JSON parser will fail with a decode error at character 0 — which looks like corrupt data rather than the actual cause. The real message is on stderr.
  • A non-zero exit does not always mean stdout is empty. Batched reads deliberately return their payload and a non-zero code, so partial results are still usable: read unavailable to see which refs missed.
  • An empty list is not an error. Listing a memory that does not exist — or that the caller cannot see — returns [] and exit 0, deliberately, so the CLI never reveals whether a resource exists. Do not treat [] as a failure, and do not infer existence from it.
  • Output on stderr is not a failure signal. Successful commands write progress and diagnostics there too — the affected-node count printed before a replace text --yes write, or the note search emits when it degrades to keyword mode on a memory with no vector index. A script that treats "stderr is non-empty" as "it failed" will misread a normal run. Use the exit code.

Unknown-flag errors are the one case that ignores --json entirely: flag parsing fails before --json is bound, so the message is plain text. Handle exit 2 without assuming the stream is parseable.

Partial writes exit non-zero

A command that writes its primary entity but can't complete a secondary step reports the partial success on stdout and exits 1 — it never prints a clean success on a half-finished write. This covers the edge-wiring commands: node import --with-edges, spec new, spec extract, and spec supersede. The node (or spec) is written and the unwireable edges are reported (unwiredEdges in --json, each with a reason), but the non-zero exit tells a script the graph isn't fully connected. spec supersede --json additionally reports each edge's status (created / failed / skipped). Re-run once the missing endpoints exist — edge wiring is idempotent.

memory set --slug on create has the same shape for a different reason: since createMemory can't set the slug, a custom slug is applied by a follow-up rename, and a failed rename leaves the memory under its name-derived slug. The memory is reported and the command exits non-zero — re-run memory set <urn> --slug <slug>.

Reference conventions

  • Memory references are org:memory URNs (e.g. acme.com:kb, or the flat acme.com:kb form) or IDs — every command that takes an ID also accepts the URN.
  • Node references are fully-qualified URNs, flat and single-colon: hrn:node:<root>:<memory>:<loc> (e.g. hrn:node:acme.com:kb:findings:flaky-ci). Keep the hrn:node: prefix (legacy urn:node: and the v1 :: form are also accepted). The prefix is what makes the reference unambiguous: the loc keeps its own colons (findings:flaky-ci), so without the type word there is no way to tell where the memory slug ends and the loc begins — a bare flat node ref is rejected for that reason. A bare loc is accepted only when you also pass -m <org:memory> (the flag fixes the arity, so bare is fine there); without -m, a bare loc is rejected with exit 2, since the same loc can exist in several memories. See URN composition.
  • Edges are directed, first-class entities: each carries an optional name (the relationship) and a loc that is its identity (hrn:edge:<org>:<memory>:<loc>). edge add --from <node> --to <node> creates an edge (optionally --name <relationship>, --loc, --description, or --runnable); edge update/edge rm address it by its edge ID (shown by edge ls and in node get --json). A nameless edge prints its loc. Cross-memory edges are allowed.
  • Null/omitted flags mean "unset" — update commands only change the fields you pass.

Write semantics

  • memory set creates when called without a positional argument (requires --org and --name) and updates when given one. On create, the URN slug is derived from --name (kebab-cased, e.g. "Project KB"project-kb) unless you pass --slug <bare-slug> to set it explicitly (so a display name and slug can differ in one command); the output echoes the resulting URN and the server-assigned class and visibility so you can confirm the effective values (an unset --class defaults to knowledge; an unset --visibility takes the server default). Because create has no slug input server-side, a custom --slug on create is a create plus a rename: if that rename fails the memory still exists under its name-derived slug and the command exits non-zero (a partial write) naming the retry. On update, --slug renames the memory, changing its URN and the URNs of every node and edge under it (their ids are stable, so links survive).
  • Three ways to create a memory. memory set picks one by which owner flags you pass:
    • Free-standing, org-owned--org <ref> --name <n> (the default described above; --class defaults to knowledge).
    • User-owned--owner-me --name <n> creates a memory with no org, owned by you in your own handle namespace, so its URN roots on your handle (hrn:mem:<handle>:<slug>). Owner-only: --class personal|private (defaults to personal); knowledge/group still require --org. The server derives the handle and bare URN — never construct it client-side.
    • App-scoped--app <ref> --agent <ref> --class app|personal|private --name <n>. Both refs accept an ID or a URN, and the Agent must be installed in the App. App-scoped create rejects --slug: App-class URNs are name-derived by the server, while personal/private URNs use a per-owner opaque id.
  • memory attach <memory> --app <ref> --agent <ref> binds an existing free-standing personal/private memory to that App and installed Agent. The memory must be caller-owned, and it keeps its URN, class, and owner — attaching re-scopes access, it doesn't re-address the memory. Typed server errors distinguish already-scoped, cross-org, membership, and install failures.
  • node add fails if the loc already exists; node update preserves unset fields. Content comes from --content "<text>", --content - (stdin), or --content-file <path>.
  • The data bag — replace vs. merge. --data '<json>' / --data-file <path> replace the node's whole data object (omit to preserve, --data null to clear). To merge a few top-level keys while keeping the rest, use --data-merge '<json>' (- reads stdin) or --data-merge-file <path>: the patch overwrites the keys it names and leaves the others intact. The merge is shallow — a nested object value is replaced wholesale, not deep-merged — the patch must be a JSON object, and --data-merge is mutually exclusive with the --data replace. (This is the CLI counterpart of the hadron_update_node_data MCP tool.)
  • --runnable (the node flag). isRunnable gates whether hadron task run will execute a node. node add and node update take --runnable to set it; on update it is tri-state--runnable sets true, --runnable=false clears it, omitting it preserves the current value. node get prints a runnable: line (and the isRunnable field in --json); node ls adds a RUN column (✓ for runnable) plus isRunnable in --json, and node ls --runnable filters server-side to runnable nodes (--runnable=false for the explicitly non-runnable; omit for all). This --runnable is distinct from the edge add --runnable flag above, which marks an edge runnable.
  • Restructuring nodes. node move <urn> (--to-urn <urn> | --to-memory <memory>) relocates a node and its whole loc-subtree (new loc and/or memory, keeping every node's identity); node clone copies it to a new loc/memory; both scope a bare source <loc> with -m, and the destination is always the explicit --to-urn/--to-memory. A cross-memory move (--to-memory, or a --to-urn in another memory) carries the subtree's embeddings and rewrites its edges into the destination, but is refused (safe subset) when either the source or the destination memory is encrypted or Git-backed, the subtree contains a chat-root node, or a moved node would break the destination memory's schema. A collision refuses with an "already exists" error. node merge <urn> --into <target> folds one node into another — --field <f> (repeatable) picks which fields to carry over, and --delete-source removes the source after. node merge mutates the target, so it requires --yes non-interactively whether or not you pass --delete-source.
  • user merge. user merge <source> --into <target> globally consolidates a duplicate source user into the surviving target and returns the target; <source> is soft-deleted, --into <target> survives. Each reference is a user id, bare handle, or hrn:user:<handle> URN, passed through verbatim (the server resolves and authorizes — platform ADMIN/OWNER, or an org ADMIN/OWNER over both members). There is no server dry-run, so it requires --yes non-interactively.
  • Soft vs. hard delete. node rm soft-deletes by default — the node disappears from reads but is recoverable from revision history. node rm --hard removes the row entirely, cascading its edges and revision history; it's irreversible and prompts with a distinct warning.
  • Destructive commands (memory rm, memory encrypt, node rm, node merge, user merge, node revision restore --truncate, node revision delete, node revision clear, edge rm, node import when it overwrites an existing node, app uninstall, ai-config rm, org rm, org member rm, memory member rm, memory share rm, auth token revoke, grant revoke, spec supersede, run cancel, schedule rm, webhook rm, webhook rotate) prompt on a terminal and require --yes when run non-interactively. Without it they exit 2. (A node import that creates a new node is never gated.)

Node revision history

Every content-changing write to a node snapshots the prior state as a revision, so edits are recoverable. node revision is the CLI over that history:

hadron node revision list <node-ref> [-m <memory>] [--limit N]
hadron node revision get <revision-id>
hadron node revision restore <revision-id> [--truncate [--yes]]
hadron node revision label <revision-id> --label <text>
hadron node revision delete <revision-id> [--yes]
hadron node revision clear <node-ref> [-m <memory>] [--yes]
  • list shows the revisions of a node (newest first), each with its id, who edited it, and any label. get prints one revision in full.
  • restore rolls the node back to a revision. By default it's non-destructive — the pre-restore state is itself snapshotted, so a restore is undoable. --truncate instead collapses every revision newer than the selected one (which becomes the new baseline), discarding that forward history — so it requires --yes.
  • label attaches a human note to a revision (e.g. --label "before the auth refactor") so you can find it later.
  • delete removes a single revision; clear removes a node's entire history and prints the count purged. Both are destructive (--yes).

Reads (list, get) need memory read access; the mutations need memory write access. A soft-deleted node is hidden from history reads, but its history stays purgeable via delete / clear (the write path). The number of revisions kept per node is capped by the memory's --max-rev-count (hadron memory set … --max-rev-count <n>); the oldest overflow is pruned as new revisions land.

Running tasks

A task is a runnable node (isRunnable: true) — usually a prompt template whose accepted --args it declares in Node.data.args. hadron task run (<node-urn> | <loc> -m <memory>) [--arg k=v]… has two modes:

  • Render (default). Compiles the node's template with your --args and prints the result — the instructions for you to run.
  • Execute (--app <ref>). Instead of rendering, the server mints a MANUAL headless run of the task under that App (a real LLM run) and prints the run id; follow it with hadron run get <id>. --as-self runs it on behalf of you (reaches your personal memories; authenticated user only). In --json, mode is "render" or "execute", and execute mode adds runId.

node import --task <ref> is the same execute path bolted onto an import: after the node is stored, the server runs <ref> against it (--task-args <json>, --app <ref>) and returns the run id as jobId.

hadron search <query> retrieves nodes ranked by relevance — the CLI's first-class search, distinct from spec find (which is scoped to spec corpora). The default mode is hybrid (semantic + keyword, fused); on a memory with no vector index it degrades to keyword and prints a note on stderr.

hadron search <query> [-m <memory>]… [--mode hybrid|keyword|vector|regex] \
  [--prefix <loc>] [--type <t>] [--tag <t>]… [--limit N] [--offset N] [-l] [--json]
  • --mode selects the ranking: hybrid (default), keyword (stemmed full-text with boolean operators — uppercase AND/OR/NOT, quoted phrases, -term), vector (semantic only), or regex (POSIX over literal fragments).
  • -m/--memory (repeatable) scopes to one or more memories by ID or URN; omit to search everything you can access.
  • --prefix filters by node loc prefix; --type by node type; --tag (repeatable) by tag.
  • --limit caps hits (default 15; 0 uses the server default); --offset pages.
  • Each hit carries a relevance score plus the node's description and abstract in --json, so results are assessable without a follow-up node get per hit. -l/--long prints the abstracts in the text output too.
hadron search "how do users report a bad actor" -m micromentor.org:mmdata
hadron search "(auth OR login) AND token" --mode keyword --prefix findings:
hadron search 'reportUser|contentConcern' --mode regex --limit 30 --json

Object store

hadron object is the collection-oriented CRUD-and-query surface over structured storage — the legible "records, not graph nodes" projection. An object is a node with an objectType, presented as a flat record { id, type, ...fields }: id/type are the reserved envelope, the node's typed properties are the top-level fields, and loc/name are auto-derived and hidden. On a memory with a declared schema, writes are validated against the collection. The command also answers to objects and obj.

hadron object create -m <memory> --type <t> (--fields '<json>' | --fields-file <path>) [--key <seg>] [--name <n>]
hadron object get    <ref>
hadron object update <ref> (--fields '<json>' | --fields-file <path>) [--reason <text>]
hadron object find   -m <memory> --type <t> [--match '<json>'] [--where '<json>'] [--sort '<json>'] [--limit N] [--offset N]
hadron object delete <ref> [--hard] [--yes]
  • create--fields is the record as a JSON object (or --fields-file). --key sets a natural id (a single loc segment, no :); omit for a server-generated id. --name overrides the auto-derived node name. id and type are reserved and can't be field names. Prints the flat record.
  • get <ref><ref> is an object id or a node URN. Exits 4 (not found) when the ref names nothing readable, or a node that isn't an object.
  • update <ref>--fields is shallow-merged into the existing record (patch wins on collision, unmentioned fields kept; atomic server-side), then re-validated against the schema. Contrast node update --properties, which replaces the whole bag. --reason is recorded in revision history.
  • find (alias ls) — query one collection. --match is an equality shorthand ({field: value}, ANDed into eq per field, cast inferred from the schema); --where is the full predicate (the search --where grammar), AND-combined with --match; --sort is {"<field>":"asc"|"desc"}. Prints the matching objects and a total.
  • delete <ref> (alias rm) — soft by default (row retained, hidden from reads); --hard removes the row. Non-recursive. --yes skips the prompt.
hadron object create -m acme.com:market --type competitor \
  --fields '{"name":"Letta","stage":"series-a","fundingUsd":12000000}' --key letta

hadron object find -m acme.com:market --type competitor \
  --where '{"path":["fundingUsd"],"as":"number","gt":10000000}' \
  --sort '{"fundingUsd":"desc"}' --json

See Work with objects from the CLI for the walkthrough and the "which layer?" guidance, and Object store API for the GraphQL + MCP equivalents.

Memory health audit

hadron memory validate <memoryRef> runs the server's memory health audit (validateMemory) and reports its findings.

hadron memory validate <memoryRef> [--check <kind>]… [--limit N] [--fail-on-findings]
Finding Meaning
broken-ref An edge pointing at a missing or soft-deleted node.
embed-failed A node whose embeddingFailedAt marker is set — an embedding attempt actually failed. Not the same as "has no vector": a node still pending, or one with nothing embeddable, lacks a vector without being a finding.
sparse No description, content, or abstract.
schema objectType / properties violating the memory's declared schema.
stale-abstract The abstract's origin hash no longer matches the content hash.

Read stale-abstract narrowly

It fires when the body isn't the version the abstract was written against — including when the change altered nothing the abstract says. It means "the body moved under this abstract", not "this abstract is wrong". Measured on a live corpus it carries essentially no signal about whether the abstract still describes the body.

Two counts, deliberately: totalFindings is the true count across every check before truncation — gate on that, never on findings.length — while matchedFindings counts what was listed after --limit and --check.

ok is false whenever a check was skipped, even with zero findings: health can't be claimed for a check that didn't run. skippedChecks says which and why — stale-abstract is skipped on an encrypted memory, because the validator doesn't decrypt.

--check <kind> filters client-side, and the server caps findings before that filter runs, so --check requests the server maximum (1000) unless you pin --limit. If the result is still truncated, the report says the filtered view may be incomplete.

Assets

hadron asset manages binary attachments stored in object storage and addressed by id or URN (hrn:asset:<root>:<memory>:assets:<id>). The URN carries its memory, which is why asset url accepts a URN alone but needs -m with a bare id.

hadron asset ls -m <memory> [--mine] [--mime <type>] [--include-deleted] [--limit N] [--offset N]
hadron asset get <asset-ref> [-o <path>|-] [--force]
hadron asset url <asset-ref> [-m <memory>]
hadron asset upload <file> -m <memory> [--mime <t>] [--name <n>] [--description <d>]
hadron asset rm <asset-ref> [--yes] | restore <asset-ref>
hadron asset link <asset-ref> --node <new-node-urn> [--name <n>] [--description <d>]
  • ls requires -m. The CLI's listing is memory-scoped by design — it won't fan out across memories. (The GraphQL API does have a cross-memory assets query returning every asset the caller can reach; the CLI simply doesn't expose it yet.) It pages to exhaustion by default; --limit fetches one explicit page. --mine narrows to your own uploads.
  • get mints a short-TTL presigned URL and streams the bytes to the asset's own filename, -o <path>, or -o - for stdout. It refuses to clobber an existing file without --force (the default name comes from server metadata, not from you) and deletes a partial file on failure.
  • upload is three steps behind one command: the server reserves the asset and returns a presigned PUT, the bytes go straight to object storage, and a final call marks it usable. Size and MIME are declared up front, so the cap and the MIME allowlist reject a bad upload before any bytes move.

Downloads are gated on virus scanning

A PENDING asset is refused because the verdict hasn't settled — that's "not yet", not a dead end: the server's sweep retries on a backoff, so try again shortly. A BLOCKED asset is refused permanently, its bytes deleted and the row kept as an audit tombstone. An upload whose bytes fail the scan fails on the last step, after the transfer, leaving a BLOCKED record on purpose — re-uploading the same file always fails the same way. All three exit 1. asset ls shows the scan status, and for a BLOCKED row the engine signature that matched (scanSignature in --json, null on every other row).

asset url prints an unauthenticated link

The public hotlink has no read gate — anyone holding it can fetch the file. It's absent (exit 5, with the reason) when the asset isn't CLEAN, its memory is encrypted, or the deployment has no public origin. Never construct that URL yourself.

Bulk search and replace

hadron replace text <old> <new> search-and-replaces a piece of text across many nodes in one call — the CLI mirror of the hadron_replace_globally MCP tool. replace is a top-level command group (not hadron node replace), and <old>/<new> are positional arguments.

hadron replace text <old> <new> --field <field>… \
  (--node <node-urn> | -m <memory>)… [--prefix <loc>] [--regex] [--ignore-case] \
  [--max-nodes N] [--reason <text>] [--dry-run] [--yes] [--json]

Parameters

  • <old> (positional) — the text to find (literal by default; a regular expression with --regex).
  • <new> (positional) — the replacement. With --regex, supports replacement patterns with backreferences ($1 for a capture group, $& for the whole match).
  • --field <field> (repeatable, required) — which text fields to search: content, name, alias, description, abstract, tags.
  • Selection — at least one of:
  • --node <node-urn> (repeatable) — specific nodes by URN or ID.
  • -m/--memory <id-or-urn> (repeatable) — every node in one or more memories.
  • --prefix <loc> — narrow a --memory scope to a parent loc and its descendants (matched on : path boundaries, so auth matches auth and auth:tokens but not authoring).
  • --regex — treat <old> as a regular expression.
  • -i/--ignore-case — fold case.
  • --max-nodes N — refuse to apply if more than N nodes would change (0 = no limit). A guard against a whole-memory rewrite from a wrong --memory URN or a forgotten --prefix.
  • --reason <text> — recorded in the node's revision history.
  • --dry-run — preview match counts without writing.
  • --yes — skip the confirmation prompt; required in non-interactive use.

The blast-radius guard

A real run first previews the per-node match counts and asks for confirmation before writing. Pass --yes to skip the prompt (an agent or script must), or --dry-run to preview without writing. Even with --yes, the affected-node count is printed to stderr before the write — combined with --max-nodes, that's the guard against an accidental whole-memory rewrite. Every change is saved to revision history, so replacements are undoable.

--json output

{
  "nodesScanned": 12,
  "nodesChanged": 3,
  "totalReplacements": 4,
  "dryRun": false,
  "results": [
    {
      "nodeId": "n1",
      "loc": "findings:flaky-ci",
      "memoryId": "mem1",
      "replacements": 3,
      "fields": ["content", "description"]
    }
  ]
}

dryRun echoes whether this was a preview. A zero-match run returns "results": [].

Examples

# Preview only — no write
hadron replace text old-url.com new-url.com \
  -m acme.com:kb --field content --field description --dry-run

# Apply across a subtree, two fields, no prompt (agent-safe)
hadron replace text foo bar \
  -m acme.com:kb --prefix services: \
  --field content --field description --yes

# Regex with a backreference on one node
hadron replace text 'api/v(\d+)/users' 'api/v$1/users' \
  --node hrn:node:acme.com:kb:api:users-endpoint --field content --regex --yes

# Cap the blast radius: refuse if more than 20 nodes would change
hadron replace text 'seperator' 'separator' \
  -m acme.com:kb --field description --field tags \
  --ignore-case --max-nodes 20 --yes

Import and export

Nodes and memories serialize to portable, self-describing files, and node import has a second mode for ingesting external source.

node import — two modes

node import [<file>|-] does one of two things depending on its input:

  • Restore (default) — reconstitute a node-export file produced by node export (frontmatter-markdown, or --format json). Read - to import from stdin, so an export pipes straight into an import. The target memory and loc come from the file's own keys; -m/--memory and --loc override them to re-home a node into another memory. Outgoing edges are imported only with --with-edges (off by default). --create-only refuses to update an existing node; --dry-run classifies without mutating. The server recomputes content hashes, so a clean export→import round-trips losslessly.
  • Content — ingest raw external source (a web page, a captured HTML DOM, a Markdown file, or a PDF) and let the server convert it to the node's Markdown body. This mode is selected by --url, by --as-content (force it for an otherwise-ambiguous .md/.json/stdin source), or automatically for a .pdf/.html file. Target the node with -m/--memory + --loc, or with --node <node-urn>. Extra flags: --content-type (text/html · text/markdown · application/pdf; inferred from the extension, but required for a PDF over stdin), --name, --type (defaults to webpage, or info for a PDF), and --properties / --properties-file (a provenance JSON object merged into the node). A PDF's text layer is extracted to Markdown — the CLI base64-encodes the file for you; scanned/image-only PDFs error server-side.

An import onto an existing node overwrites it — and is gated

If the target loc already holds a node, node import overwrites it (the prior version is kept in history). Like the destructive commands, that prompts on a terminal and requires --yes non-interactively. An import that creates a new node is never gated. With --with-edges, an edge that can't be wired is reported under unwiredEdges (with a reason) and the node is still written — but the command then exits 1 (see Partial writes); re-import is idempotent.

Memory export, clone, and extract

  • memory export <id-or-urn> [--out <dir>] writes every node to a local directory (--out defaults to .) as one frontmatter-markdown file per node (<out>/<loc>.md; colons in the loc become path segments) — the same layout the server's git sync produces, but on disk and with no remote. data-type nodes are skipped; nodes the read API can't return are listed under unavailable in the --json summary (a client-side export is bounded by per-node read access). Existing files are overwritten; files for removed nodes are not deleted.
  • memory clone <id-or-urn> --target-urn <org:slug> deep-copies a memory (nodes, edges, pending edges) into a new memory named by --target-urn (a fully-qualified org:slug URN), rewriting references to the source URN inside node content and abstracts. The target org may differ from the source's, cloning into another org — you must be a non-reader member of that target org (cross-org clones drop edges to nodes in other memories). Version history, shares/subscriptions, assets, and git-sync config are not copied; encrypted and agent-system / app memories cannot be cloned.
  • memory extract <parentRef> <targetUrn> [--move] lifts a parent node and its whole loc-subtree into a new memory named by <targetUrn> (a fully-qualified org:slug URN), making the parent the new memory's root. Locs are rebasedfindings:auth becomes the memory slug and findings:auth:oauth becomes <slug>:oauth. <parentRef> is the parent node's ID or fully-qualified URN; pass -m <org:memory> with a bare loc instead. Edges wholly inside the subtree carry over; boundary-crossing edges and unresolved pending edges are dropped. Without --move the subtree is copied (source untouched); --move relocates it, soft-deleting the source subtree — --move needs source write access and cannot target the source root (that would empty the source; clone then delete instead). The new memory preserves the source's class, so an extract never widens who can read the content (a member-restricted group stays group, a personal/private source stays owner-owned). The target org may differ from the source's, dropping the extract into another org. v1 limitation: node content is copied verbatim — because both the slug and node locs change, URN references among the moved nodes will break. Encrypted and system / app memories are rejected. See the GraphQL extractParentNodeToMemory mutation for the underlying operation. Not yet in a tagged CLI release (merged in hadron-cli#229; the latest release, v0.6.1, predates it) — until the next release ships, invoke the GraphQL mutation directly with hadron api.
# Round-trip one node through a file (or a pipe)
hadron node export hrn:node:acme.com:kb:findings:flaky-ci -o flaky.md
hadron node import flaky.md --with-edges

# Ingest external source as a node's content (server converts it)
hadron node import --url https://example.com/post -m acme.com:kb --loc clips:post
hadron node import paper.pdf -m acme.com:kb --loc papers:attention
hadron node import notes.md --as-content -m acme.com:kb --loc notes:today

# Export a whole memory to local markdown, one file per node
hadron memory export acme.com:kb --out ./kb-backup --json

# Extract a subtree into a new memory (copy, then a move into another org)
hadron memory extract hrn:node:acme.com:kb:findings:auth acme.com:auth-kb
hadron memory extract -m acme.com:kb findings:auth other-org:auth-kb --move

Team chat

hadron chat is a low-friction surface for a team chat — a shared memory where several agents and humans coordinate. Each message is a message-type node under a common parent, the payload in the node's data (author/body/timestamp, plus identity/role and parsed @mentions), ordered by a server-assigned seq. A reply is a reply edge from the new message to the one it answers. It's the same protocol you can drive by hand with node add + edge add; chat just does the plumbing. For the full workflow (kickoff prompts, push delivery), see the Set up an agent team chat how-to.

Naming a chat

Identify a chat by the node whose direct children are its messages:

  • --node <urn> — the message-parent node URN (hrn:node:<org>:<memory>:<loc>), one copyable value that packs the memory and the message location. This is the primary form.
  • -m <memory> --messages-loc <prefix> — the equivalent two-field form (what the push channel's memory + messagesLoc encode). Mutually exclusive with --node.

These, and the agent's handle/identity/role, resolve from a flag, then the matching HADRON_CHAT_* env var, then the project-local .hadron/config.json (the same file the push channel reads):

{
  "handle": "iris",
  "chat": {
    "node": "hrn:node:acme.com:team-chats:team-chat:api:messages",
    "identity": "Claude Fable 5",
    "role": "Backend Engineer"
  }
}

chat.node is the single-URN form; chat.memory + chat.messagesLoc is the two-field form the push channel requires (keep those if you run the channel). With config in place, a turn is just chat read --since <seq> / chat post --body "…".

chat read — pull new messages

chat read [--since <seq>] returns messages after <seq> (omit or --since 0 for all history) in one call — a compact transcript ([<seq>] <author> (<role>): <body>), or with --json:

{"messages":[{"seq":42,"loc":"…","author":"iris","identity":"Claude Fable 5","role":"Backend Engineer","timestamp":"…","body":"…"}],"nextSince":42}

Pass nextSince back as --since next turn — that's the whole cursor.

chat post — send a message

chat post builds the timestamped, colon-safe loc, assembles the data payload (parsing @mentions from the body), writes the message node, and — with --reply-to <loc> (the target's loc is in chat read's output) — adds the reply edge, all in one call. It also best-effort materializes the message-parent node so the chat is a real, copyable node in the portal.

The body comes from exactly one of --body <text> (inline), --body - (stdin), or --body-file <path> (a file, for a composed multi-line message). A human sets --identity human; an agent passes its model name.

# One-URN addressing, explicit identity
hadron chat post --node hrn:node:acme.com:team-chats:team-chat:api:messages \
  --handle iris --role "Backend Engineer" --body "@rufus schema looks good"

# Config-backed: read the whole thread, then reply
hadron chat read --since 41 --json
hadron chat post --reply-to team-chat:api:messages:2026-06-21T213400Z-rufus \
  --body "shipping it"

Team: personas and coding sessions

hadron team coordinates a team of humans and AI agents. A persona is an Agent carrying persona metadata (personaName / personaRole / personaPrompt) — a named team member ("Iris") re-driven across many sessions, by the same human or a different one. A session binds one git worktree to one persona and records the provenance of the work. The model behind both is in Teams, personas, and sessions.

Shipped

The team group is shipped end to end: team persona, team session (including the worklog milestones — session log (--pr | --issue | --commit | --branch) — and the session list --pr <ref> provenance query, after a one-time team init), and team chat (the group chat, backed by the server's team-chat operations — hadron-server#939).

team persona — the roster

hadron team persona create --role <role> [--name <n>] [--team-agent <ref>] (uses --app)
hadron team persona list [--org <ref>] [--role <r>]
hadron team persona get <name-or-ref>
hadron team persona retire <name-or-ref> --yes
  • persona create is a thin wrapper over the server's createTeamPersona (hadron-server#935): the server locates the Team Agent (or takes --team-agent), reads the roles:<role> definition from its system memory, allocates a free name from the role's register (a caller-supplied --name wins — a taken one is the typed PERSONA_NAME_TAKEN, exit 5), composes the persona prompt, and installs the persona into the team App. The CLI passes the values through and retries nothing.
  • persona retire takes a persona off the roster. It never frees the name — commit trailers and chat history reference it forever — and there is deliberately no persona rm. Requires --yes non-interactively.
  • persona list / get narrow the agent list client-side, merging your member-org scope with your own user-owned agents (which the unfiltered listing omits). get also accepts a persona name, resolved case-insensitively; a name that is ambiguous across orgs asks for --org or a URN.

team session — binding, provenance, presence

hadron team init -m <team-memory>
hadron team session start --as <persona> [-m <team-memory>] [--repo <r>] [--branch <b>] [--transcript <path>] [--host <h>] [--tool <t>] [--model <m>] [--force]
hadron team session whoami
hadron team session log (--pr | --issue | --commit | --branch) <ref> [--action <a>] [--detail <json>] [-m <team-memory>]
hadron team session end [--summary <text>] [--session <id>]
hadron team session list [--active] [--as <persona>] [--repo <r>] [--limit N] [--offset N]
hadron team session list (--pr | --issue | --commit | --branch) <ref> [-m <team-memory>]
  • session start --as <persona> records the provenance (repo / branch / host / tool / transcript path / model) server-side and writes a local binding under the worktree's git dir (resolved with git rev-parse --git-dir, not a literal .git/ — worktrees have a .git file). --host defaults to this machine's hostname; the rest are only recorded if you pass them. --as takes a persona name, an agent ID, or an agent URN, and --org disambiguates a name that exists in more than one org.
  • session whoami reads that binding back. It is local-only, no network — which is what makes it the recovery path after a context compaction.
  • Two different conflicts both exit 5. start refuses if this worktree is already bound (end that session first, or --force to replace the binding), and refuses if the persona has a still-active session elsewhere, naming who has been driving it and since when. A server-side stale-session reaper (hadron-server#930) auto-expires sessions idle beyond their window (24h by default), so a crashed session frees its persona eventually — but within that window it still reads as taken, which is why start prints the last driver and start time rather than deciding staleness for you.
  • --force never ends another driver's session. It starts yours alongside theirs. The one session --force does end is the one this worktree's own binding named, best-effort, so a replaced binding never orphans an active session.
  • session end ends the bound session. The persona is freed unless another active session still holds it — a forced takeover leaves the taken-over session open, so check session list --active. end --session <id> is the recovery path when the local binding is gone but the server session is still open. end refuses with exit 2 when the binding was started against a different --server than the current one.
  • session list (no ref) is the presence view — newest first, persona names joined in. --active and --as narrow client-side.

The commit trailer Persona: <name> is what carries the persona name into a PR; it survives a squash-merge where a branch name doesn't.

The worklog — recording and querying milestones

The worklog is the append-only record of externally visible work, and the authoritative artifact↔session join. Three commands touch it:

  • team init -m <team-memory> declares the worklog collection's schema in the team App memory. Once per team, idempotent, and it preserves any other collections already declared there.
  • session start -m <team-memory> records the worklog's home in the worktree binding, so later commands don't need -m.
  • session log (--pr | --issue | --commit | --branch) <ref> appends a milestone, with --action (default worked-on) and an optional --detail JSON bag of display extras.

Refs normalize to one canonical string per artifact (owner/repo#371, owner/repo@sha, owner/repo:branch), so a URL and a short form become the same lookup key. The CLI accepts more spellings than the server does: a bare number, SHA, or branch name is qualified from the session's --repo or the git remote before it is sent, because inferring the repo is the client's job. A bare --branch value is always read as a branch name, never as owner/repo. The full grammar is the work-ref contract.

--pr and --branch additionally denormalize onto Session.prNumber / Session.branch — latest wins, display only. Every logged milestone, including --issue and --commit, counts as session liveness for the inactivity reaper. With no team memory configured, --pr and --branch degrade to that denormalization alone (--json reports "recorded": "session" instead of "worklog"), while --issue and --commit refuse — there is nowhere to put them.

session list (--pr | --issue | --commit | --branch) <ref> is the provenance query: it looks the canonical (ref, kind) up in the worklog and returns the sessions that produced the artifact. Several rows are expected and correct — a PR spanning three sessions yields three transcripts. A recorded session you can't read lists as an id-only stub rather than vanishing, so the count never silently understates the work.

The worklog commands are thin wrappers over the platform operations

session log and session list --pr delegate to the server's recordTeamWork / teamWorkItems (hadron-cli#414): team init is no longer a precondition for worklog writes, and a worker session is always App-bound, so -m is a per-call override rather than a prerequisite. A SESSION_NOT_IN_APP on a worklog write means -m named a different App's memory than the bound worker's — a mismatch to fix, not a session to restart.

team chat — the team App's group chat

hadron team chat post <body|-> [--reply-to <seq>] [--as-me] (uses --app or the binding)
hadron team chat read [--since <seq>] [--mentions-me | --mentions <ref>] (uses --app or the binding)

A thin wrapper over the server's team-chat operations (hadron-server#939): ONE well-known chat per team App, living at chats:team in the Team Agent's shared app memory and bootstrapped by the server on the first post — no init step. The server also owns message ordering (an atomically allocated per-chat seq), author derivation, and mention extraction; the CLI composes no message node and never parses mentions.

  • The App resolves from --app (or the configured App context), falling back to the worktree binding's team memory.
  • Authorship: with a session binding, post is authored by the bound persona through that session — the server verifies the session is yours, active, and of this App, and records it, so an agent message always traces to the driving human. Without a binding, or with --as-me, the post is authored by you.
  • Mentions are written as @persona-name / @handle — a multiword name by its slug (@mary-jane) — and extracted server-side into the message. read --mentions-me filters to the bound persona's mentions; --mentions <ref> takes a persona name, an agent ref, or a user handle. Filters match the stored tokens, never re-parsed bodies.
  • --since <seq> is the read cursor: only messages with a strictly greater seq return, and the response's nextSince is the value to pass next turn.
  • --reply-to <seq> wires a reply edge server-side; a seq that names no message is the typed TEAM_CHAT_REPLY_NOT_FOUND (exit 4).
# Once per team: declare the worklog collection
hadron team init -m acme.com:eng-team-shared

# Mint a persona (the server allocates the name from the role's register)
hadron team persona create --role backend-engineer --app acme.com:eng-team

# Bind this worktree and work under it
hadron team session start --as Iris -m acme.com:eng-team-shared \
  --repo acme/api --tool claude-code \
  --transcript ~/.claude/projects/acme-api/session-8f2.jsonl
hadron team session whoami

# Log milestones — the bare number is qualified from the session's --repo
hadron team session log --pr 412 --action opened
hadron team session log --commit a1b2c3d --action pushed

# Coordinate in the team chat (posts as Iris via the bound session)
hadron team chat post "@rufus rate-limit middleware is up in #412, over to you"
hadron team chat read --since 42 --mentions-me

hadron team session end --summary "Rate-limit middleware + tests"

# Who is working right now, and what did Iris touch?
hadron team session list --active
hadron team session list --as Iris --limit 20 --json

# The provenance query: which sessions produced this PR? (a URL works too)
hadron team session list --pr acme/api#412 --json

Product specs

hadron spec maintains product-spec nodes in a memory that follows the loc-as-citation convention: a spec's loc is its citation number — a legal-code-style address where each colon level is a real parent/child node. A memory uses one of two schemes:

  • flat<module>:<feature>:<rule>[:<flow>] (e.g. msg:010:02).
  • product-rooted<product>:<module>:<feature>:<rule>[:<flow>] (e.g. api:cha:010:01), for a memory that spans several products.

The scheme is self-describing — the second segment is alpha (a module) when product-rooted, numeric (a feature) when flat — and a memory should use only one. A register node holds the frozen module-code table and the number ledger. Every subcommand takes -m/--memory; specs are addressed by a bare citation, not a full node URN. Every spec subcommand resolves -m the same way — it accepts a memory PK, an hrn:/urn: URN, a bare org:memory, or a memory name.

Command Purpose
spec describe [--declare flat\|product] Report the memory's scheme (flat/product), products, modules, and counts. --declare records the scheme in the memory's data.
spec list [--prefix <loc>] List specs, optionally under a citation prefix. Paged to exhaustion unless --limit/--offset request one page. Aliased as spec ls.
spec use <memory> Set the default memory hadron spec commands use when -m/--memory is omitted (stored as spec_memory in the CLI config); pass "" to clear it. Separate from the global active memory, so switching your working memory doesn't change your spec corpus.
spec get <citation> (or --prefix <p>) Show one spec — abstract, edges, body, and a lint summary; --body-only prints just the raw body. With --prefix, dump every spec under a citation branch instead (paged; --limit/--offset fetch a single page).
spec register [--check] Print the number ledger derived from live nodes. --check reports drift against the register node and exits 5 if any is found.
spec find <query> [--match-exactly] Find specs by meaning (hybrid keyword + vector). --match-exactly switches to literal regex matching over name/loc/description/tags — use it for exact-fragment lookups such as a citation, since keyword search is now full-text ranked/stemmed rather than substring.
spec grep <pattern> Exhaustive, line-oriented search over every spec's body + abstract across the corpus (one bulk read, not a per-spec loop), printing each match as citation:line: text. Literal by default; --regex (RE2), -i fold case, --field content\|abstract, --prefix <loc> to scope. The complement to spec find — use it to discover where a token actually lives in the prose (deliberately broad, no word boundary).
spec replace <pattern> <replacement> Citation-aware bulk find/replace over spec bodies + abstracts. Word-boundary-aware by default (whole-token only, so h-read-node never hits h-read-nodes); --word-boundary=false for substring, --regex for a pattern with $1 backrefs. Gated like other bulk writes: --dry-run previews per-citation counts, --yes non-interactively, --max-specs N caps blast radius. Versioned, and re-lints the changed specs afterward.
spec new [--product <ppp>] [--module <mmm>] --title <t> … Allocate the next citation and scaffold the rubric plus edges. --new-product / --new-module / --new-feature mint their respective tiers; --contract scaffolds the tier's general-provisions contract; --dry-run previews. Creating a root also scaffolds that tier's contract (<p>:gen / <m>:000 / <f>:00) unless --no-contract.
spec new <citation> --new-path --title <t> Create that citation and every missing ancestor in one call — each with its tier template, and each created root with its general-provisions contract. Ancestors are titled from their own citation segment (srv:apiapi); rename with node update --name. Not combinable with the tier-selecting flags.
spec edit <citation> Open the spec's body and abstract together in $EDITOR (they are one logical unit). --content - / --content-file and --abstract - / --abstract-file replace a field non-interactively; an omitted field is preserved, and an unchanged field is not rewritten. --dry-run previews.
spec extract <citation> --to-feature <fff> [--rule <rr>] Split a sub-rule out of a fat parent into its own citation under another feature — piping the moved chunk via --content - / --content-file, auto-wiring the cross-ref edge. --strip-source also trims the chunk from the source body.
spec link <from> <to> [--label <l>] Cross-reference one spec from another by their bare citations — a convention-aware edge add that validates both endpoints are specs in the same corpus and synthesizes the label when omitted. --dry-run previews.
spec lint [<citation>] [--prefix <loc>] [--product <ppp>] [--module <mmm>] [--all] [--strict] Validate one spec, a subtree (--prefix, that node plus its descendants), a product, a module, or the corpus against the rubric and stability rules. Exits 5 on errors; --strict promotes warnings to errors.
spec check-tools [--prefix <loc>] Scan the corpus for hadron_* tool references and flag any that aren't a real registered tool — the drift that let stale h-* shorthand rot. Checked against a manifest baked into the binary (the union of hadron-server's MCP + runner tool registries), with a small ignore-list for known non-tools (e.g. the hadron_token cookie). Exits 5 on findings, so CI can gate on it; --json and --prefix <loc> supported.
spec citations [--src <path>]… [--exclude <glob>]… [--loose] [--stale-abstracts] [--strict] Scan source code for Spec: citations and check each against the corpus — the pointers that live outside the graph, where spec lint can't see them. Reports a citation that doesn't resolve (a typo, or a spec deleted rather than superseded) and one that resolves to a superseded spec, naming its replacement. Matching is anchored on the Spec: prefix and takes every citation on the line; --loose drops the anchor and scans every line for citation-shaped tokens (finds unconventional pointers, at the cost of false positives in prose). --stale-abstracts adds a warning when a cited spec's abstract has drifted from its body — off by default, because that's a property of the spec, not the pointer. Exits 5 on errors so it can gate CI; --strict promotes warnings too. Alias: check-citations.
spec supersede <old> --title <t> [--copy-body] --yes Retire a spec and mint its replacement with a superseded-by edge.
spec import spec-kit\|code <path> Planned extractors — not yet implemented (exit 2).

Numbering and stability:

  • Features are numbered in tens (010, 020, …); rules and flows increment by one. Allocation is monotonic — strictly above the current maximum — so a retired number is never recycled.
  • General-provisions contracts. Each tier has a reserved contract its siblings inherit: rule 00 under a feature (msg:010:00), feature 000 under a module (msg:000), and — in a product-rooted corpus — module gen under a product (api:gen). Creating a root with --new-product / --new-module / --new-feature / --new-path scaffolds that tier's contract alongside it unless --no-contract is passed; spec new … --contract adds one to a tier that lacks it. Introducing a contract into a tier that already has siblings is retroactive: lint then requires each existing sibling to be back-wired with an inheritance edge before it passes.
  • Citations are never renumbered. To relocate or replace a spec, use spec supersede; spec refuses to renumber an existing citation.
  • The register is advisory. Next-free numbers come from the live nodes; spec never writes the register node. Use spec register --check to catch a stale ledger.
  • A missing parent tier is rejected before anything is written — exit 4 (NotFound), e.g. module "srv:qqq" does not exist — create it first with --new-module. Create the tier and re-run, or use --new-path.
  • A spec is never silently orphaned. Distinct from the case above: spec new (and spec extract and spec supersede) fail loudly — exit 1 — when a required table-of-contents, cross-ref, or inheritance edge can't be wired, instead of reporting ✓ created on a node that ended up disconnected. Here the spec is written and the unwired edges are reported, so the run genuinely half-completed; fix the target and re-run, or wire the edge with hadron edge add. spec supersede --json reports each edge's status (created / failed / skipped). This is the same partial-writes contract as node import --with-edges.
  • spec find is semantic by default and relies on the memory's vector index; without one it falls back to full-text keyword search and prints a note. Like the other destructive commands, spec supersede requires --yes when non-interactive.
hadron spec describe -m acme.com:platform-specs   # which scheme?
hadron spec new -m acme.com:specs --module msg --feature 010 \
  --title "W4 — 7-day check-in" --dry-run     # preview the allocation
hadron spec lint --all -m acme.com:specs       # check the corpus
hadron spec find "re-engage idle users" -m acme.com:specs

See Read and cite product specs to find and resolve a spec, and Maintain product specs for the authoring workflow.

Coding-workflow graph

hadron coding reads, extends and validates the coding-workflow graph in a memory — the review:* checklist tree and the preflight router. Those nodes are executable infrastructure, not prose: a check is triaged by reading its edge label back to the review parent, and preflight routes symptom → finding along its outgoing edges. A malformed label makes the check or route silently stop firing — the node still exists and simply never matches again. list shows the graph as its readers see it, create adds a node with every edge that makes it discoverable, and lint detects the silent-skip defects mechanically.

hadron coding review list -m <memory> [--root <loc>] [--broken]
hadron coding review create <check-name> -m <memory> --trigger <cond> --description <d> [--scope <s>] [--tag <t>]… [--link <ref>[=<label>]]… [--seq N] [--content <text|-> | --content-file <path>]
hadron coding review lint -m <memory> [--root <loc>] [--toolchain <t>|-] [--strict] [--suggest] [--fix [--yes]]
hadron coding preflight list -m <memory> [--root <loc>] [--broken]
hadron coding preflight create <loc> -m <memory> --route <action> --description <d> [--name <n>] [--symptom <s>] [--section <heading>] [--type <t>] [--tag <t>]… [--link <ref>[=<label>]]… [--seq N] [--content <text|-> | --content-file <path>] [--no-back-edge] [--no-body-line] [--dry-run]
hadron coding preflight route <node-ref> -m <memory> --route <action> [--description <d>] [--symptom <s>] [--section <heading>] [--no-back-edge] [--no-body-line] [--dry-run]
hadron coding preflight lint -m <memory> [--root <loc>] [--strict]

A node counts as a checklist item when it sits under the parent's loc prefix (review: by default) and isn't tagged meta. Findings exit 5; --strict promotes warnings to errors. --suggest proposes label fixes and --fix applies them (with --yes to skip the prompt). --broken narrows a listing to just the malformed rows.

Adding a route

preflight create writes the three things a routed node needs to be reachable, because a node with only some of them looks healthy and isn't:

  1. the router's outgoing edge, labelled to <action> — what preflight list and preflight lint read;
  2. the mirrored back-edge, so the route reads the same when someone lands on the node from search instead of via the index (--no-back-edge to skip);
  3. the routing line in the router's body- **"<symptom>"** → [[<loc>]] — <description> — which is what a human or an agent reading preflight top to bottom actually scans.

<loc> is the target's full loc: route targets live wherever they belong (findings:…, conventions:…, ops:…), with no prefix rule. Where the routing line goes is resolved before anything is written. A router with one routing list takes it automatically; one with several headed sections needs --section <heading>; a router that routes purely by edge label needs --no-body-line. An ambiguous router is a usage error listing the headings — never a half-written route. --dry-run rehearses all three writes, including which section the line lands in, and issues none of them.

If the route edge or the body update fails, the node still exists: the command prints the exact repair — an hadron edge create line, or the routing line to paste — and exits 1, never 0.

Routing at a node that already exists

preflight create mints the node it routes to. When the node is already there — usually a finding or task somebody else wrote, and often in another memoryroute does the same wiring without creating anything:

hadron coding preflight route hrn:node:acme.com:specs:tasks:create-platform-spec \
  -m hrn:mem:acme.com:dev --route "add or change a spec in the law corpus" \
  --section "Maintaining the memories themselves"

Two differences from create:

  • --description is optional. The target already describes itself, so its own description becomes the routing line's text unless you override it.
  • A cross-memory target is referenced as `<loc>` in `<memory>`, not as a bare [[loc]]. A wikilink resolves against the router's memory, so a bare one would point at nothing there and render as a dead link with no error anywhere. The memory name is read from the server, so every accepted spelling of the ref you pass yields the same canonical label.

Re-running is safe: an existing edge is not duplicated, an edge under a different label is recognised as a different route rather than mistaken for this one, and a body that already references the target is left alone. --dry-run rehearses all three writes and issues none.

AI service configs

hadron ai-config ls lists the masked set of AI service configs resolvable in an App's chat context — every distinct config name a chat could select. It is the config picker an app or UI presents; each row is the one resolveAIConfig would pick for that name.

hadron ai-config ls [--app <id-or-urn>] [--agent <id-or-urn>] [--json]
Column Meaning
NAME Config name, unique per owner (e.g. default, fast, frontier).
OWNER Tier that owns the winning row: HADRON_SERVER, ORGANIZATION, APP, or AGENT.
PROVIDER / MODEL Provider id (anthropic, openai, glm, bedrock) and model identifier.
ENABLED Whether the config is enabled.
KEY Key preview (ellipsis + last 4 characters), or when no key is set.

Resolution and masking:

  • Resolution walks App → Agent → Org → HadronServer, deduped by name with the innermost owner winning, enabled-only. Only the agent you name with --agent contributes — sibling agents installed in the same App are not consulted.
  • Output is masked. It never carries key material — only hasApiKey and the short apiKeyPreview. --json emits the full masked record: id, name, ownerType, ownerId, provider, model, hasApiKey, apiKeyPreview, params, enabled, createdAt, updatedAt.
  • --app defaults to the configured App context (hadron app use or the global --app flag); --agent narrows to one agent. Both accept an ID or a URN.
  • You must be a member of the App, and — when --agent is given — the agent must be installed in it. App membership, not org-admin, is the bar because the result is masked. Platform admins may omit --app.
hadron ai-config ls --app acme.com:juno-app
hadron ai-config ls --app acme.com:juno-app --agent acme.com:juno --json

Create, update, and remove configs

ai-config ls is read-only and masked; to manage the underlying provider rows it resolves over, use create / update / rm:

hadron ai-config create (--app|--agent|--org <id-or-urn>) --name <n> \
  --provider <p> --model <m> [--api-key -] [--param k=v]… [--disabled]
hadron ai-config create --file <path>|-   # whole config (key included) from JSON
hadron ai-config update <id> [--name <n>] [--provider <p>] [--model <m>] \
  [--api-key -] [--param k=v]… [--enabled=false]
hadron ai-config rm <id> [--yes]
  • create needs an owner — exactly one of --app, --agent, or --org (an ID or URN) — plus --name, --provider, and --model. The name is unique per owner (1-64 chars, [a-z0-9_-]).
  • The API key is a secret. It is read from stdin via --api-key - and is never echoed back — output is the same masked record ls returns (hasApiKey + apiKeyPreview only). Pipe it in: printf '%s' "$KEY" | hadron ai-config create … --api-key -.
  • --file <path> (or --file - for stdin) reads the whole config — key included — from a JSON object, so nothing sensitive touches argv or shell history. The keys mirror the flags (all optional): app/agent/org, name, provider, model, apiKey, params (an object), enabled. The file seeds every field and an explicit flag overrides the matching one (e.g. --file cfg.json --model gpt-4o); --param replaces the file's params wholesale. Unknown keys are rejected (catches typos), and passing both --file - and --api-key - is an error (both read stdin).
  • update <id> changes only the fields you pass. --api-key "" clears the key; omitting --api-key keeps it. --param k=v (repeatable) replaces the whole params object. --enabled=false disables the config.
  • rm <id> requires --yes when non-interactive.
printf '%s' "$ANTHROPIC_KEY" | hadron ai-config create --org acme.com \
  --name frontier --provider anthropic --model claude-opus-4-8 --api-key -
hadron ai-config create --file frontier.json   # same config, key included, from JSON
hadron ai-config update cfg_123 --model claude-sonnet-4-6
hadron ai-config rm cfg_123 --yes

These rows are what ai-config ls resolves over; for the end-to-end provider setup see Configure your LLM provider.

External MCP servers

hadron mcp-server manages registered external MCP servers — the conduit that lets a headless run call tools that live outside Hadron.

hadron mcp-server ls [--org <ref>] | get <id> | tools <id>
hadron mcp-server create --org <ref> --slug <s> --name <n> --url <u> [--header 'Name: value']… [--allow <tool>]… [--disabled]
hadron mcp-server update <id> [--name <n>] [--url <u>] [--header …]… [--clear-headers] [--allow <tool>]… [--clear-allow] [--enabled|--disabled]
hadron mcp-server rm <id> --yes

Each registered row exposes its tools to runs as mcp__<slug>__<tool>, which a node's data.tools declares. Registration alone grants nothing — the run must still be allowed tool.mcp__<slug>__<tool> by the policy chain. --allow narrows which of the server's tools are exposed at all; --clear-allow / --clear-headers reset those lists rather than appending. tools <id> lists what the remote server currently advertises.

See Call external MCP tools from a flow for the end-to-end walkthrough.

Connections and grants

hadron connection grant delegates scoped access on your own external connection (email / calendar) to a specific App install — for example a headless assistant that reads your inbox or answers free/busy.

hadron connection grant create --connection <ref> --app <ref> --scopes <s>[,…] [--expires-at <iso>]
hadron connection grant ls [--connection <ref>]
hadron connection grant revoke <grant-id> --yes

You must be the connection's owner to grant on it. Scopes are drawn from mail.read, mail.send, calendar.freebusy, and calendar.read. Grants may carry an expiry and are revocable at any time.

Secrets

hadron secret manages the owner-scoped secret store. Values are write-only: create reads the material from stdin, a file, or an interactive no-echo prompt — never from argv — and ls prints only the inspectable half (name, kind, metadata, audit fields), never the value.

hadron secret create --name <n> --scope user|org|app|memory [--owner <ref>] --kind generic|webfetch-auth [--value-file -|@file]
hadron secret ls --scope <s> [--owner <ref>]
hadron secret rm <id> --yes

--scope user may omit --owner to mean the caller; the org, app, and memory scopes require --owner. A webfetch-auth secret additionally takes --type bearer|basic|header plus --url-prefix, and the server derives its metadata.type. rm requires --yes non-interactively.

Agents

An Agent is a builder's creation — the thing an App installs and runs (see Building an agent). hadron agent manages their lifecycle.

  • agent ls — the member-scoped view: agents in orgs you belong to. Filter with --org, --type ASSISTANT|CHATBOT, --visibility ORGANIZATION|PERSONAL|PUBLIC, and page with --limit/--offset.
  • agent ls --public — a separate surface: the cross-org marketplace slice of every live PUBLIC agent, readable without org membership, so you can grab a foreign agent's URN to subscribe to or install. --type still filters; --org/--visibility don't apply (they're a usage error here).
  • agent get <ref> — one agent by ID or URN (full detail in --json).
  • agent create --org <id> --name <n> — with optional --type, --visibility, --description, --system-prompt, --system-memory <id>, and --surface <s> (repeatable). agent update <id> [<field flags>] changes only the fields you pass (--surface replaces the set).
  • agent rm <id> requires --yes when non-interactive.
# Discover a public agent, then read it
hadron agent ls --public --type ASSISTANT --json
hadron agent get acme.com:support-bot --json

hadron agent create --org acme.com --name "Support Bot" --type CHATBOT \
  --visibility ORGANIZATION --description "Front-line triage"

Headless runs

A headless run executes an entry (prompt) node under an App's identity, off any interactive session. The same execution kernel is reached three ways: a manual trigger (run), a recurring schedule, or an inbound webhook. run is also the audit and control surface — what ran, why, what it cost, and the kill switch — and ticket governs the consumable grants a gated run spends.

Every trigger names an entry node by fully-qualified URN and runs under an --app. By default the run acts as the App; --as-self runs on behalf of you — required to reach your personal memories, and usable only by an authenticated user (an App-key caller gets UNAUTHENTICATED).

run — trigger, audit, cancel

hadron run trigger --app <ref> --entry <node-urn> [--arg k=v]… [--as-self] \
  [--ai-config <name>] [--wait [--wait-timeout <dur>]]
hadron run list [--app <ref> | --org <ref>] [--status <s>]      # alias: ls
hadron run get <id>
hadron run cancel <id> [--yes]
  • trigger starts a MANUAL run now and prints the run id. --arg k=v (repeatable) sets template args (each value parsed as JSON or string). --ai-config <name> selects a named AI config for the run. --wait polls to a terminal status and then exits non-zero if the run ended non-COMPLETED (FAILED/TIMED_OUT/CANCELLED), so a script can branch on the outcome.
  • list is the audit surface — status, trigger kind, and run id. Scope with --app or --org (mutually exclusive); filter with --status (one of PENDING, RUNNING, COMPLETED, FAILED, CANCELLED, TIMED_OUT).
  • get shows one run in full: status, budgets, policy, and the failure payload when present.
  • cancel is the kill switch — transitions a live run to CANCELLED. Prompts on a TTY; requires --yes non-interactively.
hadron run trigger --app acme.com:ops --entry hrn:node:acme.com:ops:tasks:nightly-digest
hadron run trigger --app acme.com:ops --entry hrn:node:acme.com:ops:tasks:brief \
  --arg topic=security --as-self --wait --json
hadron run ls --app acme.com:ops --status FAILED
hadron run cancel run_123 --yes

schedule — recurring triggers

hadron schedule create --app <ref> --name <n> --cron '<expr>' --entry <node-urn> \
  [--tz <IANA>] [--arg k=v]… [--as-self] [--ai-config <name>] [--policy <json>] [--disabled]
hadron schedule list [--app <ref>]                              # alias: ls
hadron schedule update <id> [--cron …] [--entry …] [--enabled=false] …
hadron schedule rm <id> [--yes]

A schedule fires the entry node on a 5-field cron expression evaluated in --tz (default UTC). The runs it spawns show up in hadron run ls. --policy is a trigger-layer allow-list, e.g. '{"allow":["comm.outbound"]}'. update changes only the fields you pass (--enabled=false disables; --policy "" clears the allow-list). rm requires --yes non-interactively.

hadron schedule create --app acme.com:ops --name nightly-digest \
  --cron '0 6 * * *' --tz America/New_York \
  --entry hrn:node:acme.com:ops:tasks:nightly-digest --as-self

webhook — inbound triggers

hadron webhook create --app <ref> --name <n> --entry <node-urn> \
  [--args-schema <json>] [--as-self] [--ai-config <name>] [--policy <json>] [--disabled]
hadron webhook list [--app <ref>]                               # alias: ls
hadron webhook rotate <id> [--yes]
hadron webhook rm <id> [--yes]

A POST to the webhook's URL fires the entry node. The URL path and platform token are printed ONCE, at create and rotate — store them then; the secret is never queryable again (list never shows it). --name is lowercase alphanumeric/dash (1–64 chars) and is part of the URL. rotate invalidates the old URL/token immediately and prints the replacement once; both rotate and rm require --yes non-interactively.

hadron webhook create --app acme.com:ops --name deploy-notify \
  --entry hrn:node:acme.com:ops:tasks:on-deploy

ticket — action-ticket ledger

hadron ticket mint --org <ref> --action comm.outbound --count <n> \
  [--app <id>] [--note <text>] [--expires <iso8601>]
hadron ticket list --org <ref>                                  # alias: ls

A ticket is a consumable grant an org ADMIN mints into the org ledger; a headless run consumes one per gated action (v1: comm.outbound). The ledger records which run consumed each ticket, and expiries. --app scopes tickets to one App (omit for org-wide); --note records why they exist.

hadron ticket mint --org acme.com --action comm.outbound --count 100 \
  --note 'nightly digest send budget'
hadron ticket ls --org acme.com --json

grant — per-user action grants

Where a ticket is a consumable budget for a metered action, a grant is a standing permission: it hands one or more actions to a specific org member so their runs pass the policy chain for those actions.

hadron grant create --org <ref> --user <ref> --action <a>[,…] [--expires <iso8601>]
hadron grant ls [--org <ref>] [--user <ref>]                    # alias: list
hadron grant revoke <id> --yes
  • create (org ADMIN) grants actions to a member. --action takes one or more action names — an exact action (memory.clone), a wildcard prefix (memory.*), or * for all. --expires sets an optional expiry. The grantee must be a live member; a grant dies with the membership.
  • ls defaults to your own grants; narrow with --org and/or --user (seeing another user's grants needs org-audit visibility).
  • revoke soft-deletes a grant by id (requires --yes).
hadron grant create --org acme.com --user u_123 --action 'memory.*' --expires 2026-12-31T00:00:00Z
hadron grant ls --org acme.com --user u_123 --json
hadron grant revoke grant_456 --yes

Organizations

hadron org manages organizations and their members. There is no org-list query — address an org by its id (the org behind a memory URN).

hadron org create --name <n> --urn <urn>
hadron org get <id>
hadron org update <id> [--name <n>] [--urn <u>] [--visible=false]
hadron org rm <id> [--yes]
hadron org member ls <org-id>
hadron org member add     <org-id> --user <id> --role <role>
hadron org member set-role <org-id> --user <id> --role <role>
hadron org member rm      <org-id> --user <id> [--yes]
  • create requires --name and --urn (e.g. acme.com). update changes only the fields you pass; --visible=false hides the org from listings. rm requires --yes when non-interactive.
  • Members. member ls lists the org's members (user id, name, email, role). member add and member set-role take --user <id> and a --role of OWNER, ADMIN, CONTRIBUTOR, or READER (case-insensitive). member rm removes a user and requires --yes non-interactively.
hadron org create --name "Acme Inc" --urn acme.com
hadron org member add org_123 --user usr_456 --role CONTRIBUTOR
hadron org member ls org_123 --json

Memory access control

Two mechanisms grant access to a memory, both under hadron memory and addressing the memory by id or org:memory URN:

  • memory memberteam membership on a (group-class) memory. Roles are owner, writer, or reader; a member is part of the memory's team and may be an owner.
  • memory share — a per-user grant on a personal-class memory only (private is owner-only; knowledge/group use org membership or team members instead). Roles are writer or reader — a share never confers ownership. Only the memory's principal may manage its shares.

Both add / create upsert: naming a user who already has a row updates their role instead of failing.

hadron memory member ls       <memory>
hadron memory member add      <memory> --user <id> --role <owner|writer|reader>
hadron memory member set-role <memory> --user <id> --role <owner|writer|reader>
hadron memory member rm       <memory> --user <id> [--yes]

hadron memory share ls        <memory>
hadron memory share create    <memory> --grantee <user-ref> --role <writer|reader>
hadron memory share set-role  <memory> --grantee <user-ref> --role <writer|reader>
hadron memory share rm        <memory> [--grantee <user-ref>] [--yes]

--grantee is a server-resolved user reference — an id, email, handle, or hrn:user:<handle> URN. On share rm (aliases revoke / delete), omit --grantee to remove your own share: the leave / stop-sharing-with-me path. Revocation takes effect on the next read. See Share a memory with someone.

  • Member vs share. Use member for the owning team (symmetric access, may include owners); use share to grant one user read or write access without adding them to the team. The role vocabularies differ accordingly — owner|writer|reader for members, writer|reader for shares.
  • member ls / share ls list the rows (user id, name, email, role). member rm and share rm are destructive and require --yes when non-interactive.
hadron memory member add acme.com:kb --user usr_456 --role writer
hadron memory member ls  acme.com:kb --json
hadron memory share create acme.com:kb --grantee usr_789 --role reader

Access checks

hadron access check answers "what access does this user have to this resource?" for a single (user, resource) pair — the server-computed effective capabilities plus the grants that confer them. It is the audit/troubleshooting counterpart to memory member / memory share, which list who can reach a memory. The answer is authoritative: it comes from the server's effectiveAccess resolver, which reuses the enforced authorization paths rather than re-deriving authz in the CLI.

hadron access check <user> <resource> [--json]
  • <user> — id, email, or handle (a leading @ sigil is accepted, e.g. @alice). Resolved to a User ID via searchUsers, which is itself scoped to the caller. An exact match on id, email, handle, or githubUsername wins; an ambiguous match is a usage error (no arbitrary pick). User URNs are not a first-class input form yet — a hrn:user: wrapper is unwrapped client-side.
  • <resource> — a fully-qualified URN: hrn:mem:…, hrn:node:…, hrn:app:…, or hrn:agent:…; or a bare AiServiceConfig id (the one URN-less kind). An under-qualified shorthand like acme.com:kb (no hrn: prefix) is rejected locally with guidance, before any network round-trip.
  • Auditing requires rights on the resource. Reading the answer requires audit permission: a platform admin, an ADMIN/OWNER of the resource's owning org, or — for a strict-owner (personal/private) memory — the memory's principal. Otherwise the server returns FORBIDDEN (exit 1). This is distinct from the subject having no access.
  • An empty grants[] is the first-class "no access" answer (exit 0). It means the subject genuinely has no grant on the resource — not that the caller was forbidden from auditing.

Output

The human view prints the resolved user and resource, a role label, a capability table (READ / WRITE / MANAGE / DELETE as ✓/✗), and the grants:

$ hadron access check holger@baragaun.com hrn:mem:micromentor.org:academy
User:     holger@baragaun.com (019d28f1…)
Resource: micromentor.org:academy (memory)
Role:     ADMIN

READ  WRITE  MANAGE  DELETE
✓     ✓      ✓       ✓

Grants:
SOURCE         ROLE   VIA
PLATFORM_ROLE  ADMIN  —
ORG_ROLE       OWNER  micromentor.org

--json emits the same data as a stable object:

{
  "user":     { "id": "...", "name": "...", "email": "...", "handle": "..." },
  "resource": { "urn": "hrn:mem:acme.com:kb", "kind": "memory" },
  "canRead": true, "canWrite": true, "canManage": false, "canDelete": false,
  "role": "writer",
  "grants": [ { "source": "MEMORY_SHARE", "role": "writer", "via": null } ]
}

Grant sources

Each entry in grants[] is { source, role, via }. source is the gate that confers the access; via names the conferring entity where one applies (for example, a node reports its memory's access as NODE_INHERITED with via set to the memory URN):

source Confers access because…
PLATFORM_ROLE the user holds a platform-level role (e.g. platform admin).
ORG_ROLE the user is a member of the resource's owning org; via is the org.
MEMORY_PRINCIPAL the user is the strict owner of a personal/private memory.
MEMORY_MEMBER the user is on the memory's team (memory member).
MEMORY_SHARE the user has a per-user share on the memory (memory share).
NODE_INHERITED a node inherits its memory's access; via is the memory URN.
APP_MEMBER the user is a member of the App.
AGENT_SUBSCRIPTION the user has an active subscription to the Agent.
AICONFIG_OWNER the user owns the tier that owns the AI service config.
PUBLIC_VISIBILITY the resource is public and readable by anyone.

Exit codes

Code Condition
0 success — including the "no access" answer (empty grants[]).
1 the caller lacks audit rights on the resource (server FORBIDDEN).
2 usage error — under-qualified resource, or an ambiguous user match.
4 the resource could not be resolved (not found / not visible).
hadron access check alice@acme.com hrn:mem:acme.com:kb
hadron access check @alice hrn:node:acme.com:kb:start-here
hadron access check usr_123 hrn:app:acme.com:support --json

See Debug PERMISSION_DENIED errorsaccess check is the fastest first diagnostic when a call is denied.

Authentication

Authenticating and operating from the CLI never requires the web portal — a self-hosted hadron-server is sufficient. There are portal-free ways to get a hdr_user_* credential:

Mechanism Use
hadron auth login Full server-side OAuth against the configured server (discovery → Dynamic Client Registration → 127.0.0.1 loopback → PKCE → token exchange). No portal in the path; the token is stored in the OS keychain.
printf '%s\n' "$TOKEN" \| hadron auth login --with-token Store an existing hdr_user_* token — e.g. one bootstrapped on the server host with pnpm admin:mint-token, or minted by auth token create elsewhere.
HADRON_TOKEN env var Overrides stored tokens — CI and agent contexts.

hadron auth status answers "am I signed in?" via exit code (0 yes / 3 no).

Transport and credential-store safety

  • HTTPS is enforced for the bearer token. The CLI refuses to send your credential over cleartext http:// to a non-loopback host. To point it at a trusted self-hosted server over plain HTTP, set HADRON_ALLOW_HTTP=1 — the escape hatch is explicit and scoped to http (it never admits a non-http scheme). A loopback host (127.0.0.1, localhost) is always allowed without the flag.
  • Failures are loud, not silent. A corrupt auth.json fails with a clear error instead of masquerading as "not signed in", so you fix the store rather than re-authenticating blindly. When no OS keychain is available, login warns that the token is stored unencrypted on disk rather than storing it silently.

Personal access tokens

Once signed in, mint and manage long-lived hdr_user_* personal access tokens directly from the CLI — no portal:

hadron auth token create [--label <text>]   # mint a PAT — the raw key is shown ONCE
hadron auth token ls                          # list your PATs (masked to a preview)
hadron auth token validate                    # check a token: exit 0 valid / 3 rejected
hadron auth token revoke <id> [--yes]         # revoke one

auth token requires a user login — an app/agent key can't manage user tokens. The raw key is printed only once on create, so capture it then; ls shows just a masked preview. validate checks whether a token authenticates without storing it — it reads the candidate from stdin (printf '%s' "$TOKEN" | hadron auth token validate), so it never lands in your shell history, and exits 0 (printing the owning user) if the server accepts it, 3 if not.

Server info

hadron server-info reports the hadron-server this invocation targets — {url, version, baseUrl, authenticated}.

hadron server-info --json

It works signed out (the field is public), so it doubles as a reachability probe: a failure reaching the server points at the server or the network, not a missing login. (A credential store that can't be read still fails loudly rather than silently querying anonymously.)

Two fields are easy to misread:

  • version is the server's API-surface contract version — bumped when the query/tool surface changes in a caller-visible way. It is not a release version, so use it to decide whether a surface exists, not to compare against a release tag.
  • url is where the query was sent; baseUrl is what the server calls itself. A mismatch between them is worth noticing — it usually means a proxy or a stale --server / hadron config set server value.

The escape hatch: hadron api

Anything the curated commands don't cover is reachable as raw GraphQL (see the GraphQL API reference):

hadron api 'query { me { id email } }'
hadron api 'query($id: ID!) { memory(id: $id) { urn name } }' -F id=mem_123
cat op.graphql | hadron api -

-F key=value binds variables — values that parse as JSON are sent as JSON, otherwise as strings. The verbatim GraphQL response envelope prints to stdout; GraphQL errors are reflected in the exit code.

Recipes

# Inspect a memory and its nodes
hadron memory get acme.com:kb --json
hadron node ls -m acme.com:kb --json

# Read one node's content and edges (keep the hrn:node: prefix)
hadron node get hrn:node:acme.com:kb:findings:flaky-ci --json

# Create a node from stdin (-m takes a memory ref — single- or double-colon)
cat finding.md | hadron node add -m acme.com:kb --loc findings:flaky-ci \
  --name "Flaky CI" --content -

# Connect two nodes
hadron edge add --from hrn:node:acme.com:kb:findings:flaky-ci \
  --to hrn:node:acme.com:kb:start-here --name routes-to

# Bulk search-and-replace (preview, then apply with --yes)
hadron replace text old-domain.com new-domain.com \
  -m acme.com:kb --field content --field description --dry-run
hadron replace text old-domain.com new-domain.com \
  -m acme.com:kb --field content --field description --yes

# Delete non-interactively (agents must pass --yes)
hadron node rm hrn:node:acme.com:kb:findings:flaky-ci --yes