radial human CLI #
radial writes validated Radial records directly to the selected actor's PDS.
Output references use at://...#CID; keep the suffix when pinning an exact
artifact version.
Authenticate each account once. Passwords are accepted through stdin, never
argv, and only the resulting rotating session tokens are stored. Each account's
PDS is resolved from its identity, so accounts on different PDSes need no extra
configuration; pass --pds URL only to override that:
printf '%s\n' "$ALICE_APP_PASSWORD" | radial auth login \
--profile alice --identifier alice.example --password-stdin
printf '%s\n' "$BOB_APP_PASSWORD" | radial auth login \
--profile bob --identifier bob.example --password-stdin
The complete human-only loop uses the same request/artifact commands for both versions:
SPACE=$(radial space create --profile alice --name radial --description 'Open development')
radial member add --profile alice --space "$SPACE" \
--actor bob.example --kind human --role member
PROJECT=$(radial project create --profile alice --space "$SPACE" \
--name radial --git-url https://example.com/radial.git)
GOAL=$(radial goal create --profile alice --project "$PROJECT" \
--title 'Ship the feature' --body 'Acceptance criteria go here')
REQUEST_V1=$(radial request create --profile alice --goal "$GOAL" \
--type plan --assignee bob.example)
PLAN_V1=$(radial artifact post --profile bob --request "$REQUEST_V1" \
--title 'Queue route and verdict form' --body-file plan-v1.md)
radial review post --profile alice --subject "$PLAN_V1" \
--verdict request_changes
REQUEST_V2=$(radial request create --profile alice --goal "$GOAL" \
--type plan --assignee bob.example --based-on "$PLAN_V1")
PLAN_V2=$(radial artifact post --profile bob --request "$REQUEST_V2" \
--prev "$PLAN_V1" --title 'Queue route and verdict form' --body-file plan-v2.md)
radial review post --profile alice --subject "$PLAN_V2" --verdict approve
radial member remove --profile alice --space "$SPACE" --actor bob.example
goal label --set "a, b, c" writes the goal's whole label set, comma
separated; --set "" clears every label. There is no --add or --remove:
computing one needs the goal's current winning set, which is a fold rather than a
record read, and the sidecar resolves records. The web app, which does hold a
fold, does the read-modify-write itself and always calls --set. Labels are
trimmed, inner whitespace collapsed, and deduplicated case-insensitively keeping
the first spelling; a set over 20 labels or a label over 100 characters is
refused rather than truncated, because a record over either bound is one
every materializer drops while the write still looks like it succeeded.
Because a later write replaces an earlier one entirely, two members relabelling
one goal inside a single ingestion window lose an edit — the same race a
closeGoal already accepts. Relabelling recovers it.
view save --space REF --name NAME [--label L]... [--state live|ended|all] [--project REF] saves your named filter over the space's goals; a goal is in
it when it carries every --label, matches --state (default live) and, with
--project, sits under that project. Views are keyed by (author, name), so
re-saving a name is how one is edited and two members may each hold one called
the same thing. view delete --space REF --name NAME writes a tombstone under
the name rather than deleting anything — deleting the record itself would just
let the previous version take over. There is no view list: the fold lists them.
goal create --origin accepts a pinned foreign record locator in the form
at://did/collection/rkey#CID. Radial verifies that exact record version exists,
but deliberately does not validate the foreign value against Radial's lexicons.
The CID is required so imported provenance cannot silently move to rewritten text.
artifact post requires --title: a short phrase naming the artifact (at most
200 characters), shown wherever the artifact is listed rather than read. It is
trimmed, and a blank or over-long value is refused. The field is optional in
the lexicon — records signed before it existed can't be given one, and readers
fall back to a line derived from the body for those — but every writer
requires it.
space create also publishes the built-in artifact-type registry records: the
goal-scoped plan and implementation, and the project-scoped system
artifacts architecture, architecture-inventory, glossary, and
conventions (all living), and adr and fdr (append-only — a decision about
the system's shape and the design of one feature). Use --no-builtins only
if another caller will seed them. --json returns structured refs for
scripts.
member remove syncs the space, resolves the target's current addMember
grant, and writes a removeMember revoking that exact grant. Removal retains
nothing: readers ignore every record authored by a DID with no active grant,
and re-adding the member restores trust in their records.
Review findings files contain a JSON array. Each entry has severity (info,
warning, or error) and body, with optional path and line.
A thread message may be a reply: message post --parent REF threads it under
another message on the same target and derives root from it, and --mention HANDLE|DID (repeatable) records who it addresses, as DIDs. Replying to an
agent's question is what unblocks the request it parked — only a reply from
somebody other than the asker counts.
message list is how you find the REF to pass. Like type list it is a
read: it indexes the target's space and prints the thread, oldest first, one
tab-separated row per message — pinned at://…#CID locator, author DID,
timestamp, flags, and the first line of the body. A goal's thread includes
messages anchored to its artifacts:
radial message list --profile alice --goal "$GOAL"
at://…/com.disnetdev.radial.message/3lbq…#bafy… did:plc:reviewer 2026-07-21T10:02:30Z - v2 resolves the strongref properly…
at://…/com.disnetdev.radial.message/3lbq…#bafy… did:plc:tim 2026-07-21T10:11:00Z reply Reads the index twice how? If it is…
QUESTION=$(radial message list --profile alice --goal "$GOAL" --unanswered | cut -f1)
radial message post --profile alice --goal "$GOAL" --parent "$QUESTION" --body 'The openRequests scan.'
The flags classify each row: reply (it has a parent), question (it
carries re and is not itself a reply — what a turn wrote back to its
requester), unanswered (a question whose request is still parked on it), and
declined. --unanswered keeps only unanswered questions, so finding the
message blocking a request is a one-liner. --json prints the same fields
with full bodies, plus depth and inReplyTo for the nesting. --project REF reads a project's own thread instead.
comment bless is how a comment from somebody with no grant in the space
reaches an agent, and the only way one does. It resolves the guest's message
from their own PDS — the resolver reaches any DID, which is the whole reason a
non-member's record is readable at all — refuses it if the record does not
itself name the goal you passed, and writes a blessComment carrying that
record pinned by uri#cid plus a copy of its body. The copy is the point:
nothing polls a guest's repo, so an unblessed comment is in no index and no
turn bundle, and what a blessing puts in front of an agent is bytes you signed
rather than whatever that repo serves later.
radial comment bless --profile alice --goal "$GOAL" \
--comment at://did:plc:guest/com.disnetdev.radial.message/3lbq…
radial comment retract --profile alice --bless "$BLESS"
comment retract writes the tombstone; whether it takes effect — the
blessing's own author, or an active admin — is decided when a materializer
folds it, as with request retract. space guest-comments --enabled true|false is the admin switch for whether the web app asks non-members for
comments; it changes nothing about what anybody may write, and it does not
withdraw a blessing.
Inside a turn container the command allowlist is narrower and the daemon
anchors every record it writes: artifact submit, review submit, answer submit (an answer turn's reply in the goal thread), and message post (the
can't-complete question). message post --parent is refused there rather than
ignored — a turn must not believe it threaded a reply when it actually posted
a bare question.
Private spaces #
A private space keeps its records off every PDS: each one is sealed into a
signed envelope and stored in a replica under the data directory, and the same
runCli() writes it (design §18). Every ordinary command is unchanged — the CLI
matches a command's at:// arguments against the replicas this machine holds and
routes the whole command through the envelope writer when one of them matches.
Two at:// arguments matching two replicas is an error, not a guess.
radial space create --private --name Skunkworks # prints the space URI
# use the SAME data directory as radiald; start it long enough to publish and catch up,
# then stop it completely before the CLI command below
radial ticket mint --space "$SPACE"
# restart radiald before the recipient syncs; CLI and daemon must not overlap on this replica
radial ticket accept --ticket radial-ticket-1.… # on the invitee's machine
radial private list | private status --space "$SPACE" --sync | private sync
radial device list | radial device rotate
radial private blob add --file picture.png # prints the ref for image create
radial private blob list | private blob get --cid <cid> --out picture.png
radial private export --space "$SPACE" --out backup.jsonl # the backup
radial private import --file backup.jsonl --ticket radial-ticket-1.…
The ticket is the out-of-band bootstrap: it names the space, its founder and the
founder's signing key, so a fresh replica knows whose public repo to read the
device directory from and can refuse to join when that repo publishes a different
key. It grants no membership — that is an admin's member add, exactly as in
a public space — and it reveals that the space exists, so it is a secret.
A join bookmark makes a private space listable, not bootstrap-capable. Every
new machine or browser replica needs a fresh out-of-band ticket whose hints name
an active addressed member. With the same data directory, start radiald long
enough to publish an address and catch up, stop it completely, mint the ticket
with radial, then restart radiald before the recipient syncs. The CLI and
daemon must never overlap on that replica.
It also pins the space record by cid, and ticket accept keeps that pin in the
replica's space.json. A space record that arrives later under the same URI but a
different cid is a second genesis, and every command that would use the fold
refuses it by name; radial private status reports it instead, which is the
command to run when one of those refusals appears.
Five rules the commands enforce or report:
- The directory is public, and only the directory.
device,deviceAddress, and your ownjoinbookmark go to your PDS; everything else is an envelope. A key published inside an envelope would vouch for itself. - A writer serves one bus. A PDS-backed writer refuses a private space and an envelope writer refuses a public one, both fail-closed.
- A blob is named by its own bytes.
private blob addis the private counterpart ofuploadBlob: it stores bytes in this replica, keyed by the CID they hash to, and prints the reference to pass toimage create— which refuses a blob this replica does not hold, because a record naming bytes nobody has is a picture no sync can repair.private blob listnames what is held and what records here point at and this replica lacks. - Rotation is fold-neutral hygiene.
device rotatemints a new key, retires the old one locally, and withdraws its published address; everything it signed keeps counting and a stolen old key remains trusted while the DID remains a member. - Retiring disconnects a device; it does not un-count one.
device retire <deviceKeyId>withdraws that device'sdeviceAddress, so within a directory poll no replica dials it and none will serve it — the answer to a machine you no longer have, runnable from any machine that still has the session, including for a key this one has never held. It changes no observer's index. A key stolen together with your repo credentials republishes its own address, so end that session at your PDS first. - There is no per-device revocation. Remove a compromised member, which drops their whole corpus; keeping that person after a stolen key requires creating a new space.
- An archive is a bag of envelopes, never a trust input.
private exportwrites the replica's envelopes as JSON Lines andprivate importoffers every one of them to the same admission gate a peer's envelope passes. It carries no device directory (public, and re-polled), no signing key, and no bootstrap: a restore onto a machine holding no replica needs the ticket, because only a ticket can say which genesis record is the space. It does carry the want list and the blobs, so a restore does not look complete when it is not — and does not bring back the record for a picture without the picture.
The CLI holds no endpoint, deliberately. The protocol two replicas speak is in
core (private/wire.ts, sync.ts) and this machine's endpoint belongs to
radiald run (docs/adr-private-mode-iroh.md §20), which serves the SAME replica
under <dataDir>/private/<space>/. So a write here commits to that replica and
reaches nobody until the daemon carries it, and each write says so; a second
endpoint in this package would be a second replica of one space on one machine,
and would drag the native binding into a package that stays dependency-free. Run
one at a time against a space — the daemon and this CLI are two SQLite writers.
Your replicas are the only copies of the corpus: back one up with
private export, and drill the restore before trusting a space with work.