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 hrn:mem:<root>:<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>]… [--scope <name|id|app|global>] [--mode hybrid|keyword|vector|regex] [--prefix <loc>] [--type <t>] [--tag <t>]… [--limit N] [--offset N] [-l]
hadron scope list [--owner-org <ref> | --owner-app <ref> | --owner-agent <ref>] [--name <n>] | get <name|id> [--by-name] | create <name> (--owner-org|--owner-app|--owner-agent <ref>) -m <memory>… [--description <d>] | update <name|id> [--by-name] [--name <new>] [-m <memory>…] [--description <d>] | rm <name|id> [--by-name] [--yes] | explain <name|id> [--by-name] [--loc <address>] | set-active|use <name|id|app|global> [--no-verify]
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 skill lint (-m <memory>... | --all | --node <ref>...) [--strict] [--json] | status (-m <memory>... | --all) [--host <host>] [--to user|project|plugin|<dir>] [--strict] [--json] | export [--dry-run] [--force] [--prune] [--json] | plugin --out <dir> [--name <name>] [--scope <name|id>] [--zip] [--dry-run] [--json]
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 channel list [--owner-app <ref>] [-m <memory>] | get <id|address> | create <name> -m <memory> --loc <loc> [--description <d>] | update <id|address> [--name <n>] [--description <d>] | rm <id|address> [--yes] | read <id|address> [--since <seq>] [--before <seq>] [--limit N] [--offset N] [--mentions <ref>] | post <id|address> <body|-> (--session <id> | --as-me) [--reply-to <seq>] | mark-read <id|address> --attendee <ref> --seq N [--owner-app <ref>] | read-state <id|address> --attendee <ref> [--owner-app <ref>] | register list [--channel <ref>] [--attendee <ref>] [--owner <ref>] [--org <id>] [--limit N] [--offset N] | register add --channel <ref> --owner <ref> (--attendee <ref> | --all-attendees) [--role both|post|watch] [--mention-only] [--description <d>] | register set <entry-id> (--role <r> | --mention-only[=false] | --description <d>)... | register rm <entry-id> [--yes]
hadron team init [--app <ref> | -m <team-memory>] (uses --app, the context, or the binding)
hadron team worker cast --name <n> (--role <role> | --agent <ref>) [--prompt-override <text>] [--dry-run] (uses --app) | ls [--include-retired] (uses --app or the binding) | get <name-or-id> | release <name-or-id> [--yes] | retire <name-or-id> --yes | rm <name-or-id> --yes
hadron team role ls [--team-agent <ref>] (uses --app or the binding) | get <role> [--team-agent <ref>] | create <role> [--description <d>] [--team-agent <ref>] | update <role> --description <d> | rm <role> [--yes]
hadron team session start --as <worker> [-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 [--handoff <text> | --handoff-file <path>] [--summary <text>] [--session <id>] | list [--active] [--as <worker>] [--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 run [-m <memory>] [--base <ref>] [--head <ref>] [--diff <path|->] [--root <loc>] [--all] [--limit N] [--offset N] | 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 (--org <id> | --owner-me) --agent <ref> --name <n> [--type <t>] [--urn <slug>] [--description <d>] | uninstall <id> | set-active|use <app-ref>
hadron app agent ls [<app-ref>] (uses --app) | agent add <app> <agent> [--training-mode] | agent rm <app> <agent> --yes
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 list [--mine] | 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> | set-active|use <orgRef> [--no-verify]
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 <ref> App context for this invocation: hrn:app:<root>:<slug>, the <root>:<slug> short form, or an App id. Persist a default with hadron app set-active <ref> (alias use), which checks the value's shape — not that the App exists — stores it in canonical form, and clears with "". Most calls need none; a scope name and search --scope app do.

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.

Memory references

Memory arguments accept a memory ID, hrn:mem:<root>:<slug>, the <root>:<slug> / <root>::<slug> short forms, and the legacy hrn:memory: prefix. A malformed memory reference is rejected locally with exit 2. A well-formed reference that cannot be found returns exit 4 from commands that look the memory up (memory get, spec get); spec list currently prints an empty list and exits 0 instead. The CLI checks that a memory URN is fully qualified, so hrn:mem:foo is malformed.

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.
  • Portal links are server-built — copy them rather than assembling one. As of v0.12.0, three commands print the portal link as a URL: line directly under urn:: node get (single-ref and batched), team worker get, and team session start's bind receipt. Those three, and not "anything that prints a URN" — worker ls carries a URN column and no URL column.

    Copy that line when citing a node or signing as a worker. A link you assemble is not inherently wrong, but you would have to be right about two things the platform holds and you may not: the portal origin, which is deployment configuration, and the exact URN, which has to be one the resolver knows. A legacy :: URN you genuinely copied still resolves — legacy forms stay valid input — but one you reconstructed from memory may not. Either mistake breaks for whoever clicks the link rather than for you, which is why the server builds it, and why this reference deliberately does not give you the route to reassemble (cor:api:230:01).

    A missing link always renders as nothing: no line, no placeholder, and never a locally-composed fallback. But why it can be missing differs by entity, and so does what survives it:

    entity the link is null when is the urn: line still there?
    node the deployment configures no portal origin (FRONTEND_URL) yes — Node.urn is non-null, so the line always prints
    worker that, or the App's URN predates the flat grammar-v2 arity only in the first case — the link is built from the same URN, so the second takes both

    Under --json the two differ again, and a consumer has to handle both: the node DTO carries portalUrl omitempty, so the key is absent when there is no link; the worker DTO carries it as a present null — an explicit "no link", which per the table above does not tell you which of the two causes applies, and so is not a reading of the deployment's portal configuration.

  • 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, the subtree contains a chat-root node, or a moved node would break the destination memory's schema. A Git-backed memory is not refused: the move is written back to the repositories instead — the moved files leave the source memory's repo and appear in the destination's. 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.

Skills

hadron skill maintains the skill surface exported from runnable task nodes. lint and status are read-only: lint touches no disk, status writes nothing. export is the only writer into your skills folders; plugin builds installable bundles elsewhere, under an explicit --out. For the task-shaped walk-through of installing, updating and recovering, see Install and update your Hadron skills.

The declaration

A node opts in by declaring properties.exports.<host> — an object keyed by skill host, each entry {name, description, enable}:

Field
name kebab-case, ≤64 chars. Stored, not derived — there is no --prefix and no org prefix field, so any prefix is part of the name you write.
description ≤1024 chars. The host truncates longer ones in its listing, so trigger phrases past the cut never fire.
enable Must be true to publish, and defaults to OFF. A declaration alone does not ship a skill — a corpus holds many runnable nodes that were never meant to be skills.

The retired top-level properties.skill and properties.claudeSkill are read as aliases for the claudeSkill host, so nothing needs migrating. exports.claudeSkill wins over both, so the new shape can sit beside an old one. The enable default applies either way: a node carrying only {name, description} is declared and not enabled.

skill lint

Checks the corpus. What it verifies: name present, kebab-case and ≤64; description present and ≤1024; isRunnable set; body non-empty and frontmatter-free; and no two nodes storing one name.

What it cannot check: with no prefix source, a name's prefix is unverifiable — hadon-foo lints clean.

--all walks what the server lists for you: own-org, shared-with-you, and other orgs' public memories, every class. A per-user agent memory is never listed, so name it with -m.

Errors exit 5. Warnings alone exit 0 unless --strict promotes them.

skill status

Reads both sides and reports drift. It walks <root>/*/SKILL.md — --to user is ~/.claude/skills, project is <git toplevel>/.claude/skills, plugin is <git toplevel>/plugins/hadron-cli/skills, and anything else is taken as a directory — then pairs each file to its node by the id in its provenance header.

The drift class is the server's word, so the vocabulary cannot drift between the CLI, MCP and the portal.

  • A file that exists but does not parse gets class: null with parseFailure: true — a parse failure is a failure, not an eleventh class. Read the two together and never class alone. In the table that row's class cell is — with the reason in DETAIL; in --json the class key is present and null, never omitted, so "the server returned no class" stays distinguishable from "not asked for".
  • A file that parses and carries no Hadron header is somebody else's skill: never listed, moved or removed.
  • A file that does not parse is reported either way — attributed to its node when the provenance header below the broken frontmatter is still readable, and otherwise listed locally under unparseable, never sent, since an unidentifiable file must not be claimed as an orphan.
  • An unreadable file is listed under unreadable with its errno, likewise never sent.

There is deliberately no --node: the orphan and collision classes are properties of a set.

A selection that resolves to no memory sends nothing and says so — it is not an all-clear.

The symbolic roots are host-specific, so a --host other than claudeSkill must name a directory with --to <dir> rather than being resolved against Claude's. --to plugin applies no visibility filter — it covers every accessible task node, as the other targets do; curating which tasks a bundle carries is a later feature.

An error finding exits 5; drift and warnings alone exit 0, so a CI gate is an explicit --strict, which exits 5 on any drift, parse failure, orphan, empty scope, or warning.

skill export

hadron skill export [--dry-run] [--force] [--prune] [--json]

Writes every enabled declaration you can read, for every known host, into user-level roots, creating an absent root. It takes no selector, does no host detection and never prompts.

Host Root
claudeSkill ~/.claude/skills/<name>/SKILL.md
codexSkill ~/.agents/skills/<name>/SKILL.md

The deprecated ~/.codex/skills is never written, moved or cleaned. The server plans every action and renders the whole file. Export writes that body byte for byte, atomically, and reports what actually happened on disk.

  • --dry-run reports the same outcome and changes nothing, not even an absent root.
  • A hand-edited file is refused (refused), even when its declaration was disabled and it would otherwise be removed. --force overrides that for one invocation, and also regenerates a Hadron file that carries no node id. A file with no Hadron provenance header is never touched.
  • Orphans are reported (orphaned) and removed only with --prune. A removal — a disabled declaration, a move's old directory, or a prune — deletes SKILL.md, then the directory only if it is empty. Anything left is listed in kept.
  • Links are refused. If a host's root, or any directory between $HOME and it, is a symbolic link, nothing is written for that host: failure is set and every one of its skills is failed. A skill directory that is itself a link is refused. $HOME itself is resolved, not refused.
  • A declaration the host would truncate is skipped. A description over 1,024 characters is not written, and an older installed copy stays at its old version, so a skipped row is worth reading.

--json is {dryRun, hosts:[{host, root, failure, scanned, judged, written, moved, removed, skipped, refused, failed, orphaned, pruned, unreadable, unparseable}], unrecognized:[…]}, and every list is [] when empty. An item is {node, nodeId, name, reasons, kept}; moved items add from. Each reason is {code, message, origin}: origin: "server" is the planner's reason verbatim (a drift class or lint rule), and origin: "client" is an I/O fact about this machine (root-is-link, skill-dir-is-link, io-error, directory-kept, …). unrecognized lists nodes whose exports names no host, once, attributed to no host.

One failing item never stops the others. Exit 0 when nothing was refused or failed; 5 after the full report when any item was refused or failed, or a host could not be written. A run that cannot start exits with that error's code (auth, connection). Hosts load skills at session start.

hadron skill export --dry-run
hadron skill export
hadron skill export --prune --json

skill plugin

hadron skill plugin --out <dir> [--name <name>] [--scope <name|id>] [--zip] [--dry-run] [--json]

Builds one installable unit per host under an explicit, required --out, never inferred from the working directory or a git checkout. For the task-shaped walk-through, see Build a Hadron skills plugin.

Host Artifact
claudeSkill <out>/<name>/: a Claude plugin (.claude-plugin/plugin.json plus skills/<skill>/SKILL.md) that is also a one-plugin marketplace, so claude plugin marketplace add <out>/<name> then claude plugin install <name>@<name> installs it with no git
codexSkill <out>/<name>-codex/: skill folders for ~/.agents/skills
  • --zip also writes <name>.zip and <name>-codex.zip, with the plugin at the zip's root. It needs a filesystem with hard links.
  • --name defaults to hadron: lowercase words joined by hyphens, up to 64 characters. Skills are invoked as /<name>:<skill>.
  • The Claude manifest's version is derived from the bundled content (0.0.0-h<hash>), so it changes exactly when a skill does. Claude Code updates on any version change.
  • Selection is every enabled declaration you can read, unless --scope narrows it to that scope's readable memories. A scope with no readable memory never plans and exits 2. A scope that doesn't exist or isn't readable exits 4 ("no scope … is readable here"), and a scope name needs an App context (--app, or an active App) or it is refused with exit 2.
  • Refused before any request, exit 2: an artifact at, inside or above a host skills directory (.claude/skills, .agents/skills, .codex/skills, user- or project-level), an empty --out or --scope, or a bad --name.
  • An existing artifact is replaced wholesale only if this command wrote it (a .hadron-plugin marker, or the zip's comment). Anything else at the path, or a symbolic link, is left alone and fails the host (artifact-not-ours / artifact-is-link), even when it appears mid-build.
  • --dry-run writes nothing and reports the same refusals.

Each item is bucketed by the server's planned action: included, skipped, refused, failed, or notForHost (declared only for the other host). --json is {dryRun, name, out, scope, hosts:[{host, format, artifact, zip, version, failure, scanned, judged, included, skipped, refused, failed, notForHost, findings}], unrecognized}. scope is null when unscoped, and otherwise {id, name, memoryCount, droppedCount, resolvedVia}. Items have the same shape as skill export's, with kept always []. findings lists every lint finding except an error on a refused or failed entry. It never affects the exit, and passes unknown severities through.

Exit 5 after the full report on any refused or failed item or a host failure, and 0 otherwise (skipped and notForHost don't count); 2 for a bad flag, --out, or an empty scope; 4 for a scope that doesn't exist or isn't readable.

hadron skill plugin --out ~/hadron-plugins --dry-run
hadron skill plugin --out ~/hadron-plugins --zip

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>]… [--scope <name|id|app|global>] \
  [--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) restricts to one or more memories by ID or URN; omit to search everything you can access. Under a scope, -m narrows within it.
  • --scope searches under a named scope: a scope name (needs an App context — --app or hadron app set-active), a scope id, app (the App's attached memories — also needs an App context), or global (your active organization's view — set it with hadron org use). Without --scope, the default from hadron scope use applies, if you set one. Passing --scope "" does not switch the default off; clear it with hadron scope use "". A name or app with no App context is refused with exit 2 before any network call.
  • The result says which scope applied. When one did, the text output starts with scope: <label> (<kind>, via <source>), flags a default you did not pass on this invocation, and reports how many of the scope's memories you could not read and were not searched. In --json, the envelope carries a scope object: selectedBy (flag or config), kind, label, source, ownerUrn, memoryUrns and droppedCount.
  • --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
hadron search "retrieval" --scope research --app hrn:app:acme.com:dev-team

Search scopes (hadron scope)

A scope — a "lens" — is a named, ordered list of memories owned by exactly one organization, App, or Agent. It narrows what a search looks at; it never widens what you may read. Memories in a scope that you cannot read are reported as a count, never by name.

hadron scope list [--owner-org <ref> | --owner-app <ref> | --owner-agent <ref>] [--name <n>]
hadron scope get <name|id> [--by-name]
hadron scope create <name> (--owner-org|--owner-app|--owner-agent <ref>) -m <memory>… [--description <d>]
hadron scope update <name|id> [--by-name] [--name <new>] [-m <memory>…] [--description <d>]
hadron scope rm <name|id> [--by-name] [--yes]
hadron scope explain <name|id> [--by-name] [--loc <address>]
hadron scope set-active|use <name|id|app|global> [--no-verify]
  • Names and ids. A scope is addressed by its id (32 hex characters), which needs no context, or by its name, which resolves in an App's context (--app, or the active App). That context is the App's own scopes, those of the Agents installed in it, and its organization's; where names collide, the server resolves App > Agent > organization — never a union. A name with no App context is refused with exit 2 before any network call. A scope name may be 1–64 characters of [a-z0-9_-], so an all-hex 32-character name looks like an id: pass --by-name to force the name reading on get, update, rm and explain. set-active has no --by-name, so to make such a scope your default, look up its id with scope get <name> --by-name and pass the id.
  • create takes exactly one owner flag and at least one -m. Creating on an organization needs CONTRIBUTOR or above there; on an App or an Agent, ADMIN of its organization, or ownership of a user-owned one. An Agent need not be installed anywhere to own a scope. -m is repeatable and ordered: the order you pass is the scope's order, and it decides which memory wins for an address. The server enforces the name rules — unique per owner, and never app or global, which are reserved.
  • update changes only the flags you pass. -m replaces the whole list rather than appending, so pass every memory, in order. --description cannot clear a description: --description "" stores an empty string.
  • rm deletes the lens, never the memories it listed, and frees the name immediately. It needs --yes when non-interactive.
  • explain shows the scope as it applies to you: which memories it resolves to, in order; which owner level it resolved at; how many memories you cannot read; and any lower-precedence scopes of the same name that it shadowed. --loc <address> also names the memory that would win for that address. A name that two installed Agents both own is refused as ambiguous (SCOPE_NAME_AMBIGUOUS); pass the id instead.
  • set-active (alias use) stores a default search scope in ~/.config/hadron/config.toml, which search applies whenever --scope is omitted — and says so on every result. It takes the values --scope takes. A name or id is verified when you set it (a scope you cannot read is refused with exit 4); app and global are stored as keywords and resolved on each search. --no-verify skips the check for offline use. The value is stored as typed, not pinned to an id, so a name resolves in whichever App context you search from. "" clears it.
hadron scope create research --owner-org acme.com -m hrn:mem:acme.com:papers -m hrn:mem:acme.com:notes
hadron scope explain research --loc findings:retrieval --app hrn:app:acme.com:dev-team
hadron scope use research --app hrn:app:acme.com:dev-team   # a name needs an App context
hadron scope use ""   # clear the default

scope list --json shows memories: [] for every scope

list does not fetch each scope's memories, so its --json entries carry an empty memories array that means not requested, not empty. Use the memoryCount field, or scope get for the list itself.

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 a reason) when the asset isn't CLEAN, its memory is encrypted, the deployment has no public origin, or — the default — the deployment hasn't turned public hotlinks on (ASSET_PUBLIC_HOTLINK_ENABLED). The CLI's reason doesn't name that last case yet; if it blames encryption or the origin for a clean asset in an unencrypted memory, hotlinks are most likely off. 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 hrn:mem:<root>:<slug> deep-copies a memory (nodes, edges, pending edges) into a new memory named by --target-urn — the fully-qualified hrn:mem:<root>:<slug> form (the short <root>:<slug> and legacy <root>::<slug> spellings are accepted too) — 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 rebased — findings: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

Agent team chat (hadron chat)

Two different chats, and this is not the team App's one

hadron chat drives an agent team chat: message nodes in a memory you name, for an ad-hoc chat outside a team App. The team App's single group chat — the one a worker posts to as itself — is hadron team chat, further down. They share a word and nothing else.

hadron chat is a low-friction surface for an agent 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 a team of agents, prefer the team App's built-in chat — see Set up an AI team.

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"

Channels

hadron channel works with Channels — durable, ordered message streams. Every App has a default Channel named team at chats:team, which is what hadron team chat writes to; channel is the same contract addressed by Channel rather than by App.

A ref is the Channel's id OR its address (chatRootUrn, printed by channel list). chatRootUrn is null for some Channels — the id always works.

Reading and posting

hadron channel list [--owner-app <ref>] [-m <memory>]
hadron channel read <id|address> [--since <seq>] [--before <seq>] [--mentions <ref>]
hadron channel post <id|address> <body|-> (--session <id> | --as-me) [--reply-to <seq>]
  • read --since is strictly greater, and the output reports nextSince — use that rather than computing the next watermark yourself. --before pages backward.
  • post requires --session or --as-me. Without one the server records the human silently, which is the wrong authorship with no error. The flag is mandatory so the choice has to be made.

Read state

hadron channel mark-read <id|address> --attendee <ref> --seq N [--owner-app <ref>]
hadron channel read-state <id|address> --attendee <ref> [--owner-app <ref>]

Unread is derived — the Channel's watermark minus the attendee's position — so there is no count to repair, only a position to move.

The register

hadron channel register list [--channel <ref>] [--attendee <ref>] [--owner <ref>]
hadron channel register add --channel <ref> --owner <ref> (--attendee <ref> | --all-attendees) [--role both|post|watch] [--mention-only] [--description <d>]
hadron channel register set <entry-id> (--role <r> | --mention-only[=false] | --description <d>)...
hadron channel register rm <entry-id> [--yes]

The register never grants access. A row declares intent; what an attendee may read or post is the host memory's decision. Adding a row confers nothing and removing one revokes nothing — to change who can reach a Channel, change access to the memory hosting it (memory member / memory share).

  • An entry carries an attendee (a Worker or an Agent), a role (BOTH reads and posts, POST posts, WATCH reads), mentionOnly, and an owner (an App or an organization) that is the context the attendee is named in.
  • register add needs exactly one of --attendee <ref> or --all-attendees. On the wire the wide entry is the absence of an attendee, and the server reads an omitted one as "everyone" — but the CLI refuses rather than let a forgotten flag widen a declaration. The wide form has to be asked for by name.
  • register set changes only --role, --mention-only and --description. The attendee and the Channel are immutable, so moving a registration is rm + add.

Not the worker-name register

This is the chat register. The worker-name register — an ordered list a casting drew names from — was removed in server#1050 and is unrelated. Every register hit in the CLI's own GraphQL files is that retired one.

Team: workers and coding sessions

hadron team coordinates a team of humans and AI agents. A persona is dressing on an Agent (personaRole), whose systemPrompt is a {{name}}-templated prompt; the named team member ("Iris") is a worker — the casting of an installed agent into a team App, re-driven across many worker sessions by the same human or a different one. A worker session binds one git worktree to one worker and records the provenance of the work. The model behind both is in Teams, workers, and sessions.

This page documents v0.11.0 and later

server#1050 dropped the curated name register and hadron-cli#496 followed it, shipping in v0.10.0: --name is required on a cast, --team-agent is gone from it, the whole team role names group with its range and convention flags no longer exists, and worker release arrives. v0.11.0 then added session end --handoff and wired WORKER_HELD.

On v0.9.0 or earlier, team role and team worker cast refuse against the current server — they ask for TeamRole.register, which the server removed, and you get "the server rejected a query this hadron build sends". Check with hadron version and upgrade; the GraphQL calls in Set up an AI team are the fallback if you cannot. team session and team chat are unaffected either way.

team worker — the staff

hadron team worker cast --name <n> (--role <role> | --agent <ref>) [--prompt-override <text>] [--dry-run] (uses --app)
hadron team worker ls [--include-retired] (uses --app or the binding)
hadron team worker get <name-or-id>
hadron team worker release <name-or-id> [--yes]
hadron team worker retire <name-or-id> --yes
hadron team worker rm <name-or-id> --yes
  • worker cast mints a worker in ONE platform call (castWorker). The server resolves the agent — --agent names it, or --role picks the single installed agent whose personaRole matches (WORKER_AGENT_NOT_FOUND exit 4, WORKER_AGENT_AMBIGUOUS exit 2 — never a guess) — takes the name, binds the template, provisions the worker's working memory, and returns the resolved boot briefing, printed on success. --prompt-override layers per-worker individuality over the template.
  • --name is required. A name is permanent within the App (cor:agt:020:02), so it is chosen and never derived — the server will not invent a permanent identifier nobody picked. A nameless cast is refused exit 2 with the remedy, and the server's own WORKER_NAME_REQUIRED maps to exit 2 too. The claim is one attempt: WORKER_NAME_TAKEN (exit 5) is the answer, not a signal to retry.
  • --role is no longer validated against defined roles. Cast lists are ergonomics, never a gate (cor:agt:020:00 §1). An unrecognised role resolves no agent (WORKER_AGENT_NOT_FOUND); with an explicit --agent it is simply the casting's label. --team-agent is gone from cast — casting reads no system memory now, so the flag had become accepted-and-ignored.
  • WORKER_AGENT_NOT_INSTALLED is the server's refusal when an explicit --agent names an Agent that exists but is not on this App's roster — casting requires it installed at cast time. The CLI does not map it to a dedicated exit code the way it does the two role-resolution refusals above. Clearing it means hadron app agent add <app> <agent>, which takes a narrower permission than casting does: the App's owner or an org CONTRIBUTOR+, never a plain AppMember (see staffing a team). So a member who may cast can hit this and be unable to clear it alone.
  • worker cast --dry-run (castWorkerPreview) runs the cast's exact resolution — same arguments, same typed refusals — up to but not including the writes, and shows the name, the resolved agent, the composed boot prompt, and a warning when the template never binds {{name}}. A refusal on the dry run is the answer the real cast would give. It reserves nothing: no lease exists, so a previewed name may be gone at cast time.
  • worker ls is the "who is on this team?" read — the App's staff, with retired workers hidden unless --include-retired. That flag is load-bearing: retired workers keep their names forever, so the default listing under-reports what a cast will refuse. (hadron app agent ls is the install roster — the cast pool — and hadron agent ls answers a different question again: every agent you can read.)
  • worker ls --json does not carry prompt. The resolved briefing is multi-KB per worker and a roster read is not where you want it; worker get <ref> is the prompt surface. promptOverride stays on the roster, being the short per-worker override rather than the composed briefing.
  • worker retire stops the worker and keeps its name reserved — PR trailers and chat history reference it forever, and there is no rename. Needs --yes off a TTY, and is idempotent.
  • worker rm is the one removal escape: it hard-deletes a never-used miscast and frees its name (WORKER_IN_USE, exit 5, otherwise).
  • worker release (v0.10.0, releaseWorker) clears the name's hold, and is the only thing that does — not session end, not an idle window, not an expiry, not a closed chat session. Release is not retire: the worker keeps working, keeps its name and its history, and the name is never freed for a different casting. Only who may bind it changes.
  • A release hands over the worker's working memory and handoff history, because those follow the name. That is the intended transfer — and the reason nothing private belongs in a worker memory. Releasing a retired worker clears the hold and nothing else, since session start refuses one and there is no next holder.
  • Two acts, gated server-side. Releasing your own name notifies nobody and does not prompt. Releasing someone else's is an admin force-release: it posts to the team chat naming both parties, and prompts unless --yes. The post is best-effort server-side, so the receipt says the server posts a notice rather than asserting one arrived.
  • It never claims a name was free. heldByUserId masks to null on deny, so a nil hold means "unheld or held and invisible to you" — there is no visibility signal to tell them apart. The command reports status: "no-visible-hold" with wasHeld and forced both null rather than saying the name is free, because a caller acting on wasHeld: false meets WORKER_HELD at the next session start. The hold is re-read immediately before the mutation, and a change between the two refuses exit 5, so a hold taken in the gap is not force-released while the receipt calls it routine. The server now also offers a real precondition — expectedHolderUserId / expectUnheld, refusing WORKER_HOLD_STALE (hadron-server#1073) — which closes the window the CLI's re-read can only narrow. The CLI does not pass it yet (hadron-cli#522); a caller going through hadron api directly should.
  • worker get shows the holder (--json gains heldByUserId / heldAt). The line is omitted rather than dashed when there is no visible hold, and there is deliberately no held boolean — it would answer "no" to a caller who merely cannot see.
  • worker get prints the worker's portal link (v0.12.0) as a URL: line under urn:, with portalUrl in --json:

    Iris (backend-engineer)
      worker: wk_123
      app: hrn:app:acme.com:eng-team — Eng Team
      agent: ag_456
      urn: hrn:worker:acme.com:eng-team:iris
      URL: https://hadron.example.com/app/u/hrn:worker:acme.com:eng-team:iris
    

    Copy that line when signing work published outside the team — see portal links are server-built. worker ls carries the URN column but no URL column: the roster is a lookup, and the link belongs on the single-worker read you follow it with.

The whole group resolves its team App ambiently — --app, then the App context, then the worktree binding — and the human render says which App it landed on and which of those answered, because two worktrees bound to different teams would otherwise print different staff identically. That line is render-only; --json stays the bare array and already carries appId and urn on every row.

team role — the role definitions

hadron team role ls [--team-agent <ref>] (uses --app or the binding)
hadron team role get <role> [--team-agent <ref>]
hadron team role create <role> [--description <d>] [--team-agent <ref>]
hadron team role update <role> --description <d>
hadron team role rm <role> [--yes]
  • role ls / get (teamRoles) read the App's role definitions: the role, its loc and node id, the description, the resolved role agent, and the {{name}}-placeholder check. The prompt template is not here — it lives on the role agent (agent get). --team-agent disambiguates when several installed agents carry a roles: branch (TEAM_AGENT_AMBIGUOUS, exit 2).
  • The register projection is gone. register, freeCount, exhausted, nameRange and nameConvention were removed from the --json shape rather than emitted empty, because a permanent [] or 0 keeps promising an allocation surface the server no longer has. "Which names are free" is not a question this answers any more: worker ls --include-retired is the roster, and a cast names its worker explicitly.
  • role create / update are thin over createTeamRole / updateTeamRole, and --description is the whole CLI write surface. The platform also lets updateTeamRole set a role's repos affinity (hadron-server#1024), which the CLI has no flag for yet (hadron-cli#456) — set it through hadron api meanwhile. An existing role refuses create (TEAM_ROLE_EXISTS, exit 5 — update is the edit path), and an update naming no field is refused exit 2 rather than sent, since omitted means "preserve" and the write would be a no-op reporting success.
  • role rm (deleteTeamRole) retires a definition and is now unconditional — the minted-name gate and --transfer-register-to went with the register, so there is no allocation ledger left to protect. The delete is soft: the roles:<role> node and its sub-nodes are tombstoned and recoverable. --yes is required off a TTY. It does not touch the role agent — that is app agent rm <app> <agent> --yes, then agent rm <agent> --yes.
  • Retiring a role never frees a name for re-casting. Names are permanent per App against the whole roster (cor:agt:020:02), and that was as true with the register as without it — which is the evidence that the register was bookkeeping about allocation rather than identity.

Removed with the register

The whole team role names set|add|rm|mv group, plus --names, --name-range, --name-convention, --clear-name-range, --clear-name-convention, --allow-out-of-range, and the expectedNames compare-and-swap behind the sugar. Their refusals — TEAM_ROLE_NAME_MINTED, TEAM_ROLE_NAME_DUPLICATE, TEAM_ROLE_NAME_OUT_OF_RANGE, TEAM_ROLE_STALE, TEAM_ROLE_IN_USE — are unreachable and no longer mapped, because the server cannot produce them. cor:agt:020:07 is withdrawn, not superseded: nothing replaces the mechanism.

team session — binding, provenance, presence

hadron team init [--app <ref> | -m <team-memory>] (uses --app, the context, or the binding)
hadron team session start --as <worker> [-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 [--handoff <text> | --handoff-file <path>] [--summary <text>] [--session <id>]
hadron team session list [--active] [--as <worker>] [--repo <r>] [--limit N] [--offset N]
hadron team session list (--pr | --issue | --commit | --branch) <ref> [-m <team-memory>]
  • session start --as <worker> binds this git worktree to a worker. It records the provenance (repo / branch / host / tool / transcript path / model) server-side, prints the worker's boot briefing, 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. The server stamps the role agent and the worker's App, so every worker session is App-bound; a retired worker refuses (WORKER_RETIRED).
  • The bind receipt carries the worker's URL: (v0.12.0), under its urn: and above the briefing — matching what hadron_start_session returns on the MCP track. It is deliberately on the same screen as the briefing that tells you to sign published work with a clickable URN, so the string that instruction needs is the one already in front of you rather than one you compose.
  • --as takes three forms, and the order they resolve in matters. A worker URN (hrn:worker:<root>:<app-slug>:<slug>) dispatches first and App-independently, so it works with no App selected and cannot be broken by a stale or unreadable App context — it is the form to reach for when you are addressing a worker across App boundaries. Otherwise the name is resolved within the team App (from -m, the persistent --app, or the App context), and failing that the argument is tried as a worker id.
  • session whoami reads that binding back. 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, and refuses if the worker has a live worker session elsewhere, naming who has been driving it and since when. Live means driven inside the idle window (24 hours by default — a deployment setting, not a platform constant), and any team tool or command that drives the session counts as activity, not just session log: the heartbeat is server-side, so it is the same mechanism the MCP tools use.

    Since hadron-server#1114 that refusal is stronger evidence than it used to be. An open session no longer blocks a bind on its own — the server computes liveness rather than ending quiet sessions — so a TAKEN refusal means somebody drove this worker recently, not that somebody once forgot to close it. start still prints the last driver and start time rather than deciding for you. - One worktree per worker (hadron-cli#472). The already-bound refusal picks its remedy by whether that session is still alive. Live, it points at git worktree add -b <new-branch> ../<name> — the -b matters, because the bare form takes an existing ref, so a fresh name fails and a tag or sha silently gives you a detached HEAD. Ended, --force is exactly right. Two agents in one checkout share one index and one working tree: git add -A sweeps the other's in-flight edits, and Session.branch is captured once at bind and never revisited, so the provenance a merged PR traces back through goes false with no signal. The guard catches a second binding only — a second agent working unbound in the same checkout does identical damage and nothing fires. - --force reaches TAKEN and never HELD (cor:agt:020:09, hadron-cli#487). A name is held by the person who binds it, and nothing about a session frees one — not an end, an idle window, an expiry; only worker release does. So binding a name held by somebody else refuses WORKER_HELD (exit 5) whether or not --force rides along, and the remedy is to cast your own worker for the role, or to ask the holder or an App/org admin to release it — never to retry with the flag. start refuses before it reaches the server when the hold is visibly another's, and renders the server's refusal when the hold is claimed in the race a pre-flight cannot close. Casting does not hold: a roster staffed for other people is unheld until each of them binds, and an App-key session holds nothing at all. - What --force does do, on a worker that is merely taken: it starts yours alongside theirs and never ends another driver's session. The one session it does end is the one this worktree's own binding named, best-effort, so a replaced binding never orphans an active session. It relabels a binding; it does not separate two agents from one working tree. - session log prints a stderr nudge counting team-chat messages since you last ran chat read, and how many mention you (hadron-cli#474) — the moment before you publish something durable is the last point a missed decision can still change what you do. The watermark it compares against is binding-local: it advances only on an unfiltered, contiguous, successfully rendered read of this binding's own App, and a read made through the MCP tools never reaches it. The note is phrased as what this worktree knows for that reason. Best-effort — the milestone is already recorded when it runs, so a failed chat read never fails the log, and session log --json is untouched. - session end --handoff writes the continuity record the next driver reads (hadron-server#1029): what landed, what is open, what is blocked, what not to repeat. The server files it in the worker's own memory and returns it at the next bind without being asked — and since handoffs follow the name (cor:agt:020:09), the next driver may be a colleague. --handoff-file <path> reads it from a file and --handoff - from stdin, because a paragraph through shell quoting is its own hazard. It is written before the session ends and a failed write refuses the end (HANDOFF_WRITE_FAILED, exit 1) rather than ending anyway: a still-bound worker is recoverable, an ended session whose handoff evaporated is not. Passing it on a session with no worker is refused — there is no sequence to write to. An explicitly empty handoff is refused exit 2; omitting it is the normal way to end without a record. Needs v0.11.0 or later (hadron-cli#505). - --summary is a different field and the next driver never sees it — a display-only label on the session row, the write-only field hadron-server#1029 was filed to fix. Both are kept because collapsing them is a decision about that feature's shape rather than this flag's. If you are writing one thing for whoever comes next, write --handoff. - session end ends the bound worker session. That clears taken — the worker can be bound again — unless another active worker session still holds it, which a forced takeover leaves behind; check session list --active. It does not release the name's hold: since server#1050 a name belongs to a person until an explicit release, and nothing about ending a session, expiring, or going quiet changes that. end --session <id> is the recovery path when the local binding is gone or unusable (including one written by a pre-Worker CLI, which whoami reports as a degraded read). 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, worker names joined in. --as narrows server-side (sessions(workerRef:)); --active narrows client-side.

Ending your chat session does not end your worker session

Two different things are called a session (hadron-server#1034). The worker session is the binding above. Your chat session is the conversation you are in — the Claude Code session, the Desktop window. Closing, archiving or losing the chat session leaves the worker session open — nothing on the server ends it but session end — and the worker reading as taken until you end it or its idle window passes. The window only lets you bind again without forcing a takeover of yourself — the name's hold is untouched, so nobody else gets past WORKER_HELD by waiting. The session, and the handoff you never wrote, stay behind either way. Never shorten "chat session" to "chat": on a team, the chat is the team chat.

The commit trailer Worker: <Name> (<role>) <hrn:worker:<root>:<app>:<slug>> is what carries the worker's identity into a PR; it survives a squash-merge where a branch name doesn't, and it is app-qualified because worker names are only unique per App. It replaces the older Persona: <name> form.

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 driving the session, so it keeps the session reading as live. 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 worker through that worker session — the server verifies the session is yours and active, and records it, so an agent message always traces to the driving human. A worker of another App may post only when the session's user can write this chat's host memory; otherwise the server refuses CHANNEL_HOST_NOT_WRITABLE and the CLI exits 8 (see hadron_team_chat_post). Without a binding, or with --as-me, the post is authored by you.
  • Mentions are written as @worker-name / @handle — a multiword name by its slug (@mary-jane) — and extracted server-side into the message. read --mentions-me filters to the bound worker's mentions; --mentions <ref> takes a worker 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

# Cast a worker — the name is yours to choose and is permanent for this App
hadron team worker cast --name Iris --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 --handoff "Rate-limit middleware + tests landed in #412. Review open; do not re-run the migration."

# 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: a node is a spec when it carries the spec tag (or the governed spec role), and its loc is its citation. Any valid node loc is a spec address, at any depth and in any shape; a loc implies no parent, and a spec's edges are the ones it was given. One caveat: spec list, get --prefix, grep, replace, check-tools and find --match-exactly select specs by the tag, so a spec that carries only the role is found by address but skipped by those scans (spec lint warns tag-spec on it). Every spec write sets the tag.

Many corpora use a legal-code numbering convention, which spec new's allocation and contract flags produce, and which spec register and spec extract operate on:

  • <module>:<feature>:<rule>[:<flow>] (e.g. msg:010:02);
  • <product>:<module>:<feature>:<rule>[:<flow>] (e.g. api:cha:010:01), when the numbering starts with a product.

It is a convention, not a rule: creating, reading, linking, editing and linting accept a spec at any loc, and a memory has no declared "scheme". Only the operations that allocate in the numbering need a numbered loc — spec new --new-path, spec extract's source, and spec supersede without --to — and each refuses any other loc with exit 2 and a pointer to the any-loc alternative. It may mix both forms, or use neither. 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 Inventory the memory's specs: specs, roots (first segments), maxDepth, and how many are legacyNumbered vs outsideNumbering. Classifies nothing. --declare is retired: it is refused (exit 2) before any request, and writes nothing. A scheme a memory's data still stores from it is shown as retired and ignored.
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. The ledger covers the numbering convention; specs at any other loc are named in outsideNumbering ([] when there are none), never dropped. --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 <loc> --title <t> Create exactly that spec, at any valid loc. No parent is required, no contract is created, no number is allocated; a numeric last segment only sets seq (sort order). Its only edge is an --inherit <loc> you name. A loc that already holds a node is refused (exit 2). Not combinable with the numbering flags below. --dry-run previews.
spec new [--product <ppp>] [--module <mmm>] --title <t> … Allocate the next citation in the numbering convention 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 numbered 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:api → api); rename with node update --name. Not combinable with the tier-selecting flags. A loc outside the numbering is refused (exit 2) with a pointer to drop --new-path.
spec edit <citation> Open the spec's body and abstract together in $EDITOR (they are one logical unit). --content - / --content-file, --abstract - / --abstract-file, and --description - / --description-file replace fields non-interactively; description is not in the default editor buffer. An omitted field is preserved, and an unchanged field is not rewritten. Abstract replacements have a 2000-character limit (UTF-16 code units, including whitespace and newlines); over-limit proposals fail before preview or save. --dry-run previews without writing; --json includes nodeId, revision, proposalHash, the proposed field changes, and descriptionChanged. Every save targets the read node ID and revision. To replay an approved preview later, pass its --expected-node-id, --expected-revision, and --expected-proposal-hash together. A changed node or proposal is refused (exit 5); re-read and preview again.
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. Allocates in the numbering convention, so the source must be a numbered citation; for any other spec, use spec new <loc> and spec edit.
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. A spec owes no parent, contract, inheritance edge or index at any loc. The rubric (abstract, "what invalidates") runs only on numbered rules and flows.
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> [--to <loc>] [--copy-body] --yes Retire any spec and create its replacement with a superseded-by edge. --to names the replacement's loc (any valid, free loc; nothing derived). Without --to, a numbered rule or flow gets the next free number; any other spec is refused (exit 2) with nothing written. --dry-run previews.
spec import spec-kit\|code <path> Planned extractors — not yet implemented (exit 2).

Numbering and stability (the allocation rules apply to the numbering convention; the stability rules apply to every spec):

  • 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. A spec inherits a contract through an inheritance edge, and only through the edge. The numbering reserves a contract per tier: rule 00 under a feature (msg:010:00), feature 000 under a module (msg:000), and — when the numbering starts with a product — 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, and allocation wires each new spec's edge to it; spec new … --contract adds one to a tier that lacks it. Existing siblings are not back-wired, and lint does not report the missing edge: link each one that should be bound with spec link <sibling> <contract> --label "inherits the shared contract (general provisions)". Outside the numbering, pass --inherit <loc> to spec new <loc>.
  • 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.
  • In numbered allocation, 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   # what's in the corpus?
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 run [-m <memory>] [--base <ref>] [--head <ref>] [--diff <path|->] [--root <loc>] [--all] [--limit N] [--offset N]
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.

review run — the checklist against a diff

coding review run returns the review checks a change could fire, with their full bodies, in one response. The change set comes from git — --base/--head, defaulting to the merge base with the default branch and the working tree, untracked files included — or from --diff <path|->, a unified diff. --diff - reads a pipe and is refused (exit 2) from an interactive terminal, because a truncated paste silently drops files. --diff with --base/--head is refused too. -m is optional: from a checkout, the memory resolves from .hadron/config.json, the configured memory, or the git remote's repository name, and the choice is printed to stderr.

Each check lands in one bucket, decided structurally from the path patterns the check names, never from its prose:

  • matched — a named path changed; matchedOn records the (pattern, file) pair that fired.
  • undecided — the check names no paths. Returned in full: "cannot tell" is not "does not apply". Most checks in a mature checklist land here.
  • excluded — the check names paths and the diff touches none. Listed with its patterns, never silently dropped; --all returns these checks too.

--limit/--offset page the checks, and nextOffset is non-null only when they withheld some. --json is an object — unlike review list's array — with memory, memorySource, base, head, diffSource, changedFiles, total, returned, nextOffset, checks[], excluded[] and unavailable[].

hadron coding review run --base origin/main --json
git diff origin/main | hadron coding review run --diff -

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 memory — route 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, or drive) 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 — there is no org-admin bypass. Scopes are drawn from mail.read, mail.send, calendar.freebusy, calendar.read, and drive.read. Grants may carry an expiry and are revocable at any time; several operations (deleting, moving, marking read, flagging and categorizing mail, and creating drive documents) are owner-only and no scope reaches them. See Grant an App access to a connection.

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.

hadron org list [--mine]
hadron org set-active|use <orgRef> [--no-verify]
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]
  • list lists organizations; --mine keeps only those you are a member of.
  • set-active (alias use) stores your active organization in ~/.config/hadron/config.toml. It is what search --scope global searches: without one, a global search is refused when you belong to more than one organization. Name the org by its root (acme.com), URN or id. The ref is verified when you set it: an org you cannot read is refused with exit 4, and one you can read but are not a member of is refused with exit 2, because a global search would silently return nothing for a non-member. --no-verify skips both checks. "" clears it.
  • 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 use acme.com
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 member — team 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 errors — access 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