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
unavailableto 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 --yeswrite, or the notesearchemits 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:memoryURNs (e.g.acme.com:kb, or the flatacme.com:kbform) 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 thehrn:node:prefix (legacyurn: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 alocthat 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 rmaddress it by its edge ID (shown byedge lsand innode 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 underurn::node get(single-ref and batched),team worker get, andteam session start's bind receipt. Those three, and not "anything that prints a URN" —worker lscarries aURNcolumn and noURLcolumn.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.urnis non-null, so the line always printsworker 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
--jsonthe two differ again, and a consumer has to handle both: the node DTO carriesportalUrlomitempty, so the key is absent when there is no link; the worker DTO carries it as a presentnull— 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 setcreates when called without a positional argument (requires--organd--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-assignedclassandvisibilityso you can confirm the effective values (an unset--classdefaults toknowledge; an unset--visibilitytakes the server default). Because create has no slug input server-side, a custom--slugon 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,--slugrenames 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 setpicks one by which owner flags you pass:- Free-standing, org-owned —
--org <ref> --name <n>(the default described above;--classdefaults toknowledge). - 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 topersonal);knowledge/groupstill 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.
- Free-standing, org-owned —
memory attach <memory> --app <ref> --agent <ref>binds an existing free-standingpersonal/privatememory 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 addfails if the loc already exists;node updatepreserves unset fields. Content comes from--content "<text>",--content -(stdin), or--content-file <path>.- The
databag — replace vs. merge.--data '<json>'/--data-file <path>replace the node's wholedataobject (omit to preserve,--data nullto 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-mergeis mutually exclusive with the--datareplace. (This is the CLI counterpart of thehadron_update_node_dataMCP tool.) --runnable(the node flag).isRunnablegates whetherhadron task runwill execute a node.node addandnode updatetake--runnableto set it; onupdateit is tri-state —--runnablesets true,--runnable=falseclears it, omitting it preserves the current value.node getprints arunnable:line (and theisRunnablefield in--json);node lsadds aRUNcolumn (✓ for runnable) plusisRunnablein--json, andnode ls --runnablefilters server-side to runnable nodes (--runnable=falsefor the explicitly non-runnable; omit for all). This--runnableis distinct from theedge add --runnableflag 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 clonecopies 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-urnin 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-sourceremoves the source after.node mergemutates the target, so it requires--yesnon-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, orhrn:user:<handle>URN, passed through verbatim (the server resolves and authorizes — platformADMIN/OWNER, or an orgADMIN/OWNERover both members). There is no server dry-run, so it requires--yesnon-interactively.- Soft vs. hard delete.
node rmsoft-deletes by default — the node disappears from reads but is recoverable from revision history.node rm --hardremoves 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 importwhen 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--yeswhen run non-interactively. Without it they exit 2. (Anode importthat 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]
listshows the revisions of a node (newest first), each with its id, who edited it, and any label.getprints one revision in full.restorerolls the node back to a revision. By default it's non-destructive — the pre-restore state is itself snapshotted, so a restore is undoable.--truncateinstead collapses every revision newer than the selected one (which becomes the new baseline), discarding that forward history — so it requires--yes.labelattaches a human note to a revision (e.g.--label "before the auth refactor") so you can find it later.deleteremoves a single revision;clearremoves 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 aMANUALheadless run of the task under that App (a real LLM run) and prints the run id; follow it withhadron run get <id>.--as-selfruns it on behalf of you (reaches your personal memories; authenticated user only). In--json,modeis"render"or"execute", and execute mode addsrunId.
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: nullwithparseFailure: true— a parse failure is a failure, not an eleventh class. Read the two together and neverclassalone. In the table that row's class cell is—with the reason in DETAIL; in--jsontheclasskey 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
unreadablewith 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¶
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-runreports 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.--forceoverrides 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 — deletesSKILL.md, then the directory only if it is empty. Anything left is listed inkept. - Links are refused. If a host's root, or any directory between
$HOMEand it, is a symbolic link, nothing is written for that host:failureis set and every one of its skills isfailed. A skill directory that is itself a link isrefused.$HOMEitself 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.
skill plugin¶
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 |
--zipalso writes<name>.zipand<name>-codex.zip, with the plugin at the zip's root. It needs a filesystem with hard links.--namedefaults tohadron: lowercase words joined by hyphens, up to 64 characters. Skills are invoked as/<name>:<skill>.- The Claude manifest's
versionis 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
--scopenarrows 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--outor--scope, or a bad--name. - An existing artifact is replaced wholesale only if this command wrote it
(a
.hadron-pluginmarker, 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-runwrites 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
Search¶
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]
--modeselects the ranking:hybrid(default),keyword(stemmed full-text with boolean operators — uppercaseAND/OR/NOT, quoted phrases,-term),vector(semantic only), orregex(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,-mnarrows within it.--scopesearches under a named scope: a scope name (needs an App context —--apporhadron app set-active), a scope id,app(the App's attached memories — also needs an App context), orglobal(your active organization's view — set it withhadron org use). Without--scope, the default fromhadron scope useapplies, if you set one. Passing--scope ""does not switch the default off; clear it withhadron scope use "". A name orappwith 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 ascopeobject:selectedBy(flagorconfig),kind,label,source,ownerUrn,memoryUrnsanddroppedCount. --prefixfilters by node loc prefix;--typeby node type;--tag(repeatable) by tag.--limitcaps hits (default 15;0uses the server default);--offsetpages.- Each hit carries a relevance score plus the node's description and
abstract in
--json, so results are assessable without a follow-upnode getper hit.-l/--longprints 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-nameto force the name reading onget,update,rmandexplain.set-activehas no--by-name, so to make such a scope your default, look up its id withscope get <name> --by-nameand pass the id. createtakes exactly one owner flag and at least one-m. Creating on an organization needsCONTRIBUTORor above there; on an App or an Agent,ADMINof its organization, or ownership of a user-owned one. An Agent need not be installed anywhere to own a scope.-mis 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 neverapporglobal, which are reserved.updatechanges only the flags you pass.-mreplaces the whole list rather than appending, so pass every memory, in order.--descriptioncannot clear a description:--description ""stores an empty string.rmdeletes the lens, never the memories it listed, and frees the name immediately. It needs--yeswhen non-interactive.explainshows 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(aliasuse) stores a default search scope in~/.config/hadron/config.toml, whichsearchapplies whenever--scopeis omitted — and says so on every result. It takes the values--scopetakes. A name or id is verified when you set it (a scope you cannot read is refused with exit 4);appandglobalare stored as keywords and resolved on each search.--no-verifyskips 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—--fieldsis the record as a JSON object (or--fields-file).--keysets a natural id (a single loc segment, no:); omit for a server-generated id.--nameoverrides the auto-derived node name.idandtypeare reserved and can't be field names. Prints the flat record.get <ref>—<ref>is an object id or a node URN. Exits4(not found) when the ref names nothing readable, or a node that isn't an object.update <ref>—--fieldsis shallow-merged into the existing record (patch wins on collision, unmentioned fields kept; atomic server-side), then re-validated against the schema. Contrastnode update --properties, which replaces the whole bag.--reasonis recorded in revision history.find(aliasls) — query one collection.--matchis an equality shorthand ({field: value}, ANDed intoeqper field, cast inferred from the schema);--whereis the full predicate (thesearch --wheregrammar), AND-combined with--match;--sortis{"<field>":"asc"|"desc"}. Prints the matching objects and atotal.delete <ref>(aliasrm) — soft by default (row retained, hidden from reads);--hardremoves the row. Non-recursive.--yesskips 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.
| 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>]
lsrequires-m. The CLI's listing is memory-scoped by design — it won't fan out across memories. (The GraphQL API does have a cross-memoryassetsquery returning every asset the caller can reach; the CLI simply doesn't expose it yet.) It pages to exhaustion by default;--limitfetches one explicit page.--minenarrows to your own uploads.getmints 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.uploadis 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 ($1for 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--memoryscope to a parent loc and its descendants (matched on:path boundaries, soauthmatchesauthandauth:tokensbut notauthoring).--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--memoryURN 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/--memoryand--locoverride them to re-home a node into another memory. Outgoing edges are imported only with--with-edges(off by default).--create-onlyrefuses to update an existing node;--dry-runclassifies 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/.htmlfile. 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 towebpage, orinfofor 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 (--outdefaults 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 underunavailablein the--jsonsummary (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-qualifiedhrn: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-qualifiedorg:slugURN), making the parent the new memory's root. Locs are rebased —findings:authbecomes the memory slug andfindings:auth:oauthbecomes<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--movethe subtree is copied (source untouched);--moverelocates it, soft-deleting the source subtree —--moveneeds 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 GraphQLextractParentNodeToMemorymutation 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 withhadron 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'smemory+messagesLocencode). 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 --sinceis strictly greater, and the output reportsnextSince— use that rather than computing the next watermark yourself.--beforepages backward.postrequires--sessionor--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(BOTHreads and posts,POSTposts,WATCHreads),mentionOnly, and an owner (an App or an organization) that is the context the attendee is named in. register addneeds 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 setchanges only--role,--mention-onlyand--description. The attendee and the Channel are immutable, so moving a registration isrm+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 castmints a worker in ONE platform call (castWorker). The server resolves the agent —--agentnames it, or--rolepicks the single installed agent whosepersonaRolematches (WORKER_AGENT_NOT_FOUNDexit 4,WORKER_AGENT_AMBIGUOUSexit 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-overridelayers per-worker individuality over the template.--nameis 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 ownWORKER_NAME_REQUIREDmaps to exit 2 too. The claim is one attempt:WORKER_NAME_TAKEN(exit 5) is the answer, not a signal to retry.--roleis 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--agentit is simply the casting's label.--team-agentis gone fromcast— casting reads no system memory now, so the flag had become accepted-and-ignored.WORKER_AGENT_NOT_INSTALLEDis the server's refusal when an explicit--agentnames 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 meanshadron app agent add <app> <agent>, which takes a narrower permission than casting does: the App's owner or an orgCONTRIBUTOR+, 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 lsis 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 lsis the install roster — the cast pool — andhadron agent lsanswers a different question again: every agent you can read.)worker ls --jsondoes not carryprompt. 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.promptOverridestays on the roster, being the short per-worker override rather than the composed briefing.worker retirestops the worker and keeps its name reserved — PR trailers and chat history reference it forever, and there is no rename. Needs--yesoff a TTY, and is idempotent.worker rmis 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 — notsession 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 startrefuses 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.
heldByUserIdmasks 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 reportsstatus: "no-visible-hold"withwasHeldandforcedboth null rather than saying the name is free, because a caller acting onwasHeld: falsemeetsWORKER_HELDat the nextsession 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, refusingWORKER_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 throughhadron apidirectly should. worker getshows the holder (--jsongainsheldByUserId/heldAt). The line is omitted rather than dashed when there is no visible hold, and there is deliberately noheldboolean — it would answer "no" to a caller who merely cannot see.-
worker getprints the worker's portal link (v0.12.0) as aURL:line underurn:, withportalUrlin--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:irisCopy that line when signing work published outside the team — see portal links are server-built.
worker lscarries theURNcolumn but noURLcolumn: 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-agentdisambiguates when several installed agents carry aroles:branch (TEAM_AGENT_AMBIGUOUS, exit 2).- The register projection is gone.
register,freeCount,exhausted,nameRangeandnameConventionwere removed from the--jsonshape rather than emitted empty, because a permanent[]or0keeps promising an allocation surface the server no longer has. "Which names are free" is not a question this answers any more:worker ls --include-retiredis the roster, and a cast names its worker explicitly. role create/updateare thin overcreateTeamRole/updateTeamRole, and--descriptionis the whole CLI write surface. The platform also letsupdateTeamRoleset a role'sreposaffinity (hadron-server#1024), which the CLI has no flag for yet (hadron-cli#456) — set it throughhadron apimeanwhile. An existing role refusescreate(TEAM_ROLE_EXISTS, exit 5 —updateis the edit path), and anupdatenaming 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-towent with the register, so there is no allocation ledger left to protect. The delete is soft: theroles:<role>node and its sub-nodes are tombstoned and recoverable.--yesis required off a TTY. It does not touch the role agent — that isapp agent rm <app> <agent> --yes, thenagent 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 withgit rev-parse --git-dir, not a literal.git/— worktrees have a.gitfile).--hostdefaults 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 itsurn:and above the briefing — matching whathadron_start_sessionreturns 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. --astakes 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 whoamireads 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.
startrefuses 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 justsession 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
TAKENrefusal means somebody drove this worker recently, not that somebody once forgot to close it.startstill 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 atgit worktree add -b <new-branch> ../<name>— the-bmatters, 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,--forceis exactly right. Two agents in one checkout share one index and one working tree:git add -Asweeps the other's in-flight edits, andSession.branchis 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. ---forcereaches 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; onlyworker releasedoes. So binding a name held by somebody else refusesWORKER_HELD(exit 5) whether or not--forcerides 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.startrefuses 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--forcedoes 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 logprints a stderr nudge counting team-chat messages since you last ranchat 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, andsession log --jsonis untouched. -session end --handoffwrites 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). ---summaryis 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 endends 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; checksession list --active. It does not release the name's hold: sinceserver#1050a 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, whichwhoamireports as a degraded read).endrefuses with exit 2 when the binding was started against a different--serverthan the current one. -session list(no ref) is the presence view — newest first, worker names joined in.--asnarrows server-side (sessions(workerRef:));--activenarrows 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(defaultworked-on) and an optional--detailJSON 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,
postis 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 refusesCHANNEL_HOST_NOT_WRITABLEand the CLI exits 8 (seehadron_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-mefilters 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'snextSinceis the value to pass next turn.--reply-to <seq>wires a reply edge server-side; a seq that names no message is the typedTEAM_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
00under a feature (msg:010:00), feature000under a module (msg:000), and — when the numbering starts with a product — modulegenunder a product (api:gen). Creating a root with--new-product/--new-module/--new-feature/--new-pathscaffolds that tier's contract alongside it unless--no-contractis passed, and allocation wires each new spec's edge to it;spec new … --contractadds one to a tier that lacks it. Existing siblings are not back-wired, andlintdoes not report the missing edge: link each one that should be bound withspec link <sibling> <contract> --label "inherits the shared contract (general provisions)". Outside the numbering, pass--inherit <loc>tospec new <loc>. - Citations are never renumbered. To relocate or replace a spec, use
spec supersede;specrefuses to renumber an existing citation. - The register is advisory. Next-free numbers come from the live nodes;
specnever writes the register node. Usespec register --checkto 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(andspec extractandspec supersede) fail loudly — exit 1 — when a required table-of-contents, cross-ref, or inheritance edge can't be wired, instead of reporting✓ createdon 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 withhadron edge add.spec supersede --jsonreports each edge'sstatus(created/failed/skipped). This is the same partial-writes contract asnode import --with-edges. spec findis 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 supersederequires--yeswhen 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;matchedOnrecords 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;--allreturns 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:
- the router's outgoing edge, labelled
to <action>— whatpreflight listandpreflight lintread; - 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-edgeto skip); - the routing line in the router's body —
- **"<symptom>"** → [[<loc>]] — <description>— which is what a human or an agent readingpreflighttop 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:
--descriptionis 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.
| 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
--agentcontributes — sibling agents installed in the same App are not consulted. - Output is masked. It never carries key material — only
hasApiKeyand the shortapiKeyPreview.--jsonemits the full masked record:id,name,ownerType,ownerId,provider,model,hasApiKey,apiKeyPreview,params,enabled,createdAt,updatedAt. --appdefaults to the configured App context (hadron app useor the global--appflag);--agentnarrows to one agent. Both accept an ID or a URN.- You must be a member of the App, and — when
--agentis 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]
createneeds 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-64chars,[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 recordlsreturns (hasApiKey+apiKeyPreviewonly). 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);--paramreplaces the file'sparamswholesale. 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-keykeeps it.--param k=v(repeatable) replaces the whole params object.--enabled=falsedisables the config.rm <id>requires--yeswhen 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.--typestill filters;--org/--visibilitydon'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 (--surfacereplaces the set).agent rm <id>requires--yeswhen 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]
triggerstarts 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.--waitpolls 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.listis the audit surface — status, trigger kind, and run id. Scope with--appor--org(mutually exclusive); filter with--status(one ofPENDING,RUNNING,COMPLETED,FAILED,CANCELLED,TIMED_OUT).getshows one run in full: status, budgets, policy, and the failure payload when present.cancelis the kill switch — transitions a live run toCANCELLED. Prompts on a TTY; requires--yesnon-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.--actiontakes one or more action names — an exact action (memory.clone), a wildcard prefix (memory.*), or*for all.--expiressets an optional expiry. The grantee must be a live member; a grant dies with the membership.lsdefaults to your own grants; narrow with--organd/or--user(seeing another user's grants needs org-audit visibility).revokesoft-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]
listlists organizations;--minekeeps only those you are a member of.set-active(aliasuse) stores your active organization in~/.config/hadron/config.toml. It is whatsearch --scope globalsearches: without one, aglobalsearch 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 aglobalsearch would silently return nothing for a non-member.--no-verifyskips both checks.""clears it.createrequires--nameand--urn(e.g.acme.com).updatechanges only the fields you pass;--visible=falsehides the org from listings.rmrequires--yeswhen non-interactive.- Members.
member lslists the org's members (user id, name, email, role).member addandmember set-roletake--user <id>and a--roleofOWNER,ADMIN,CONTRIBUTOR, orREADER(case-insensitive).member rmremoves a user and requires--yesnon-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 areowner,writer, orreader; a member is part of the memory's team and may be anowner.memory share— a per-user grant on apersonal-class memory only (privateis owner-only;knowledge/groupuse org membership or team members instead). Roles arewriterorreader— 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
memberfor the owning team (symmetric access, may include owners); useshareto grant one user read or write access without adding them to the team. The role vocabularies differ accordingly —owner|writer|readerfor members,writer|readerfor shares. member ls/share lslist the rows (user id, name, email, role).member rmandshare rmare destructive and require--yeswhen 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.
<user>— id, email, or handle (a leading@sigil is accepted, e.g.@alice). Resolved to a User ID viasearchUsers, 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 — ahrn:user:wrapper is unwrapped client-side.<resource>— a fully-qualified URN:hrn:mem:…,hrn:node:…,hrn:app:…, orhrn:agent:…; or a bareAiServiceConfigid (the one URN-less kind). An under-qualified shorthand likeacme.com:kb(nohrn: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 returnsFORBIDDEN(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, setHADRON_ALLOW_HTTP=1— the escape hatch is explicit and scoped tohttp(it never admits a non-httpscheme). A loopback host (127.0.0.1,localhost) is always allowed without the flag. - Failures are loud, not silent. A corrupt
auth.jsonfails 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,loginwarns 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}.
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:
versionis 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.urlis where the query was sent;baseUrlis what the server calls itself. A mismatch between them is worth noticing — it usually means a proxy or a stale--server/hadron config set servervalue.
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