Running an agent #
Zero to a running agent: register agent identities, point radiald at a
space, and let it fulfill requests in containers. Start from the
readme for what Radial is; radial-json.md
is the field-by-field configuration reference.
Prerequisites #
- Docker. Turns run in containers.
- A model credential for whichever harness your profiles use — dedicated and spend-capped, since the daemon forwards it into turn containers.
gh auth login, if implementation turns will push branches and open PRs on GitHub. (A tangled project works differently and doesn't usegh— see "A tangled project" below.)- An existing atproto account for the agent, separate from your own.
Build the workspace and the turn image, and export the credential:
pnpm build
pnpm images
gh auth login # needed for implementation turns on GitHub
export ANTHROPIC_API_KEY='...' # harness "claude"; or CLAUDE_CODE_OAUTH_TOKEN
# harness "pi": export the provider's own variable instead —
# OPENAI_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, GROQ_API_KEY, …
# harness "codex": export CODEX_API_KEY or a Business/Enterprise CODEX_ACCESS_TOKEN
# (OPENAI_API_KEY does not authenticate codex), or configure
# run.codexAuth.mode = "chatgpt-session" and run: radiald codex login
For the claude harness the credential is either an ANTHROPIC_API_KEY from
the Anthropic Console (make it a dedicated, spend-capped key) or an OAuth
token minted from a Claude subscription: run claude setup-token on a
machine with a browser, approve the prompt as the account whose subscription
should carry the usage, and export the printed token as
CLAUDE_CODE_OAUTH_TOKEN. The token is long-lived — store it like a
password. Two things to know before choosing it over an API key: it draws on
the subscription's usage limits rather than metered API billing, so there is
no spend cap to configure, and agent turns share those limits with your own
interactive Claude Code sessions.
The turn image ships three harnesses — the
Claude Code CLI ("harness": "claude"), the
pi coding agent ("harness": "pi", 15+ model providers),
and the Codex CLI
("harness": "codex") — and each profile names the one it runs on. At
startup, radiald run lists the credentials it will forward, by name, and
refuses to start when a loaded profile's harness has none.
Configure #
radiald config init # or: radiald config init agent.example
This prompts for the agent's atproto handle and writes
~/.config/radial/radial.json with three profiles on that one identity:
planner (plan plus the system types architecture,
architecture-inventory, glossary, conventions, adr, and fdr),
implementer, and reviewer (which also carries answer, the built-in that
replies in a goal's thread). Profiles inherit the top-level pds, harness,
and models, and each publishes its own agent record, so one identity can
offer several capability sets:
{
"identifier": "agent.example",
"harness": "claude",
"models": ["claude-opus-4-8=high"],
"agents": {
"planner": {
"artifactTypes": [
"plan",
"architecture",
"architecture-inventory",
"glossary",
"conventions",
"adr",
"fdr"
]
},
"impl": {
"harness": "claude",
"models": ["claude-sonnet-4-5=high"],
"artifactTypes": ["implementation"]
},
"reviewer": {
"harness": "pi",
"models": ["openai/gpt-5.2=high"],
"artifactTypes": ["review", "answer"]
}
},
"run": {
"spaces": ["at://did:plc:operator/com.disnetdev.radial.space/example"],
"stateDir": ".radial-state",
"image": "radial-turn:latest",
"concurrency": 1,
"forge": { "kind": "github" }
}
}
harness is resolved per profile, so a reviewer on one provider's model and
an implementer on another's run side by side in one daemon. The defaults are a
60-minute turn timeout (15 minutes for a check), one concurrent turn, and a
4 GB container memory floor. Turn transport defaults to auto: Unix sockets
on Linux, the host.docker.internal TCP bridge on macOS/Windows (a
local-development escape hatch — never expose it to a LAN).
Things worth knowing, with the details in
radial-json.md:
- Config location. A
radial.jsonin the working directory or any parent takes precedence over~/.config/radial/radial.json;--config <path>or$RADIAL_CONFIGoverrides both. Running two daemons on one machine is a matter of giving each config its own"dataDir". artifactTypesnames types from the space's registry. The registry is data: an admin addssecurity-reviewby writing a record, and every client offers it from then on — but a type no profile publishes leaves its requests sitting open with nobody to assign.radiald runwarns about uncovered types at startup, once per space.reviewandanswerare built-ins, not registry data:radial type createrefuses both names. A profile still has to list them to be offered one, which is why the scaffold puts both on the reviewer.- One profile can accept different types in different spaces. Add a
"spaces"block to it, keyed by space URI; that space's list overridesartifactTypesrather than adding to it, and an empty list means the profile does nothing there. See One profile, different capabilities per space. - The PDS is resolved from the identity itself. Set
pdsexplicitly only to override that, such as against a local PDS in testing.
Register the profiles #
One app password covers all of them, read from stdin or $RADIAL_PASSWORD
and never stored. init is idempotent and prints what each profile now
publishes:
printf '%s\n' "$AGENT_APP_PASSWORD" | radiald init --password-stdin
✓ planner did:plc:… plan, architecture, architecture-inventory, glossary,
conventions, adr, fdr
✓ implementer did:plc:… implementation
✓ reviewer did:plc:… review, answer
radiald init planner # or just the named ones
Editing a profile's artifactTypes later changes nothing on its own — the
published agent record is what an assignee menu and auto-review read.
Republish with radiald init <profile> --update; without --update a changed
profile is refused rather than rewritten, so a second config or a stale
checkout can't quietly redefine what a running agent advertises. A private
space is the exception in both directions: init cannot help there (its record
goes to a PDS, which a private space's fold never reads), and it does not need
to — radiald run publishes each profile's capabilities into the replica
itself, so editing radial.json and restarting the daemon is the whole of it.
A profile may override identifier to use a second agent account, whose app
password is read from $RADIAL_<IDENTIFIER>_PASSWORD (e.g.
RADIAL_OTHER_EXAMPLE_PASSWORD for other.example). And a single profile can
be registered entirely from flags, no config file:
printf '%s\n' "$AGENT_APP_PASSWORD" | radiald init planner \
--pds https://pds.example \
--identifier planner.example \
--name planner \
--harness pi \
--model openai/gpt-5.2=high \
--artifact-type plan \
--password-stdin
--harness takes claude, pi, or codex; anything else is refused rather
than published.
Run #
The daemon serves the spaces listed in run.spaces. You need a space with
the agent added as a member (kind agent, role agent) and a project
pointing at a git repository — create them in the web app, or with the
radial CLI (walkthrough). Put the space's
URI in run.spaces, then:
radiald run
It polls the space every five seconds by default, starts one isolated
container per eligible request, and keeps durable records and turn ledgers in
run.stateDir.
Commission work by creating a request on a goal and assigning it to the agent
— the ⊕ menu in the app, or radial request create --type plan --assignee agent.example …. A plan turn gets a read-only checkout and submits its
artifact through the sidecar. An implementation turn gets a writable
checkout, pushes a radial/impl-* branch, opens or updates a PR, and submits
its branch, commit, and PR link; it never merges. Requesting another version
against a returned implementation continues its branch and PR ("One PR per
version chain" below).
Stop the daemon with Ctrl-C; it kills active containers and records
interrupted attempts. radiald turn list prints the ledger; if a request
exhausts its retry limit or needs to be retried immediately, run radiald turn reset <request-uri>.
Another harness, another provider #
Moving a profile to pi — and with it to any provider pi supports — is one field and one republish:
# 1. in radial.json: "harness": "pi", "models": ["openai/gpt-5.2=high"]
# 2. the published agent record is what an assignee menu reads, so republish it
radiald init reviewer --update
✓ reviewer did:plc:… review (republished)
# 3. export that provider's credential and restart the daemon
export OPENAI_API_KEY='...'
radiald run
loaded 3 agent profile(s): planner (claude), impl (claude), reviewer (pi)
model credentials forwarded to turns: ANTHROPIC_API_KEY, OPENAI_API_KEY
Pi's models entries are provider/model with an optional :<thinking>
suffix — openai/gpt-5.2, anthropic/claude-opus-4-8:high, openrouter/…;
pi --list-models lists what a provider offers. The =high half is an
operator annotation for humans reading the agent record and is never sent to
any CLI.
Moving one to codex is the same three steps: "harness": "codex" with a
bare model slug ("models": ["gpt-5.6-sol=high"]), radiald init <profile> --update, then either:
- export
CODEX_API_KEY(or a Business/EnterpriseCODEX_ACCESS_TOKEN) and restart; or - add
"codexAuth": { "mode": "chatgpt-session" }torun, executeradiald codex login --config <path>, ensure the two environment credentials are unset, and restart.
The managed-login form creates a fresh session owned by this Radial instance;
it never copies the desktop/CLI ~/.codex/auth.json. Codex turns sharing it
are serialized so token refresh is safe, and only a validated refreshed
auth.json survives a turn.
The daemon forwards a provider variable only when it is set in the daemon's
own environment, and only names it knows: each harness declares the provider
variables it understands, and run.modelEnv adds anything else — a provider
Radial does not know about, or provider configuration that is not itself a
credential (CLOUDFLARE_ACCOUNT_ID, AWS_REGION). Treat that list
carefully: everything on it is a credential the turn then possesses, and a
turn reads untrusted repositories and the open web.
radial-json.md covers the allowlist,
the log-redaction rule, and the spend-capping stance.
Nothing else about a turn changes. Every harness receives the same prompt — the bundle, the checkout, and the sidecar submission contract are protocol obligations, not CLI mechanics — and the daemon classifies the turn purely from what the harness told the socket.
A tangled project #
Radial supports more than one forge, and picks per project by the host of its
git remote — a space can hold a GitHub project and a
tangled one at once. Configure it in run.forges:
"run": {
"spaces": ["at://…"],
"forges": [
{ "kind": "github" },
{ "kind": "tangled", "knownHosts": ["tangled.org ssh-ed25519 AAAAC3Nza..."] }
]
}
knownHosts is the pinned ssh host key of each host in the adapter's hosts
list. A tangled turn pushes over ssh rather than HTTPS-with-a-token, and a
container has nobody to answer a first-connection trust prompt, so Radial
requires the operator to pin the key instead of falling back to
StrictHostKeyChecking=no — which would accept whatever answered on port 22,
on a forge where the pushed branch is exactly what the artifact record
commits to. Get the lines from ssh-keyscan tangled.org, compare their
fingerprints (ssh-keyscan tangled.org | ssh-keygen -lf -) against the ones
tangled publishes — the scan alone proves nothing — and paste each line
verbatim into the array. Without it the daemon still observes tangled
pulls; it just warns at startup and refuses to push. See
radial-json.md.
Then radiald init generates an ed25519 push key for the agent, publishes it
as an sh.tangled.publicKey record under the agent's own DID, and prints the
one step it cannot do for you: add that DID as a collaborator on each
tangled repository the agent implements in. Everything else is the same
walkthrough. Two differences are visible in the records. First, a tangled
turn pushes over ssh and does not open its own pull request — Radial writes
the sh.tangled.repo.pull record itself, as the same identity that signs the
artifact, because tangled ships no CLI a container could use. links.pr
therefore carries that record's direct https://…/pulls/<n> page only when
the page proves it names the record's exact at-uri, with that stable at-uri as
the fallback on any miss. Second, a second
version appends a round to the same pull record instead of opening a new
one. Merges are observed through Bobbin,
tangled's public read-only API appview, which needs no credential and no
configuration — set api only to point at your own instance, or to false
to read PDSes directly instead. Radial reads those PDSes anyway whenever the
appview reports a pull open, since that is also what its index says when it
has not seen the merge yet. See design §10 and
adr-tangled-forge.md.
A private space #
A private space's records never reach a PDS: they travel between members'
devices as signed envelopes (design §18,
adr-private-mode-iroh.md). Dispatch, claims,
turns, and auto-review don't know the difference — the daemon seals what its
agents write into envelopes instead of PDS writes, and the turn container
never sees any of it. What changes for the operator is how the daemon gets a
replica, and how that replica is cared for.
Private spaces get their own list in run — separate from spaces, because
they need what no PDS can provide
(radial-json.md has the field detail):
"run": {
"privateSpaces": [
{
"space": "at://did:plc:founder/com.disnetdev.radial.space/example",
"ticketFile": "/etc/radial/example.ticket"
}
]
}
Getting an agent serving one:
If the source replica is daemon-owned, start radiald long enough to publish an
address and catch up, then stop it completely and keep it stopped through steps
1–2. The CLI and daemon must not overlap on that replica.
- Add the agent as a member, exactly as in a public space:
radial member add --space … --actor agent.example --kind agent --role agent, written by an admin from a machine that holds a replica. Until that grant exists the daemon syncs and folds, but nothing its agents write counts. - Mint a ticket for the daemon's machine.
radial space create --privateprints the space URI, not a ticket; a member with a replica mints one withradial ticket mint --space …(or from the space page in the app). When using the CLI against the daemon's replica, use the same data directory and leave it stopped while minting; restartradialdbefore the recipient syncs. A ticket is a secret — it reveals the space exists — so hand it over out of band, and pointticketFileat a file rather than pasting the value into the config. The ticket is how a machine that holds nothing bootstraps: it names the space, its founder, and the genesis pin the replica is verified against. It's only needed once — after the first run, the replica's ownspace.jsoncarries the same facts. - Run.
radiald rundoes the rest at startup, in order: publishes adevicekey for each identity into its public repo, binds one iroh endpoint (its identity persists in<dataDir>/private/endpoint.key), publishes each identity's address, opens the replica under<dataDir>/private/<founder-did>~<rkey>/, and checks it against the ticket's pin.
Things to know while it runs:
- There is no polling fallback. A private space has no PDS to fall back on: records arrive only from other members' devices, so the daemon needs at least one of them reachable to catch up. In practice the daemon is the always-on replica — the one a member's browser tab or laptop syncs against.
- The daemon stays dialable on its own. Every
run.privateAddressRefreshMs(60 s default) it checks whether its endpoint moved and republishes the address only when it did, so a machine that changed networks is reachable again without a restart. - Device keys are managed with
radiald device.listshows what the machine holds,publishis the idempotent publishrunalso does,rotate --did …mints a fresh key and retires the old one (hygiene — the fold never notices), andretire <deviceKeyId>withdraws a device's published address so no replica dials or serves it again — the answer to a stolen machine. Neither un-counts anything already written; a key stolen together with the account's repo credentials can republish its address, so end the PDS session first, and remove the member if the writings themselves can no longer be trusted. - The replica is the data — back it up. No PDS holds a copy, so
radial private exportis the backup: every envelope, in the clear, and on you from there. One writer at a time, though: theradialCLI opens the same replica directory the daemon serves, so stop the daemon completely before running CLI commands against its private spaces; restart it before a recipient needs to sync. - Forge records stay public. A tangled pull request or GitHub PR is public by definition, so implementation turns in a private space still produce public forge records; only the Radial records are private. Point private spaces at repositories whose visibility you've thought about.
Inspecting what a daemon sees #
For read-only debugging, run a durable ingestion/indexing pass, then inspect the same local view without hitting the network:
radiald sync "$SPACE_URI" --records radial.db --checkpoints radial-sync.db
radiald index "$SPACE_URI" --records radial.db
The debug CLI reads one JSON record envelope (or an array of envelopes) per file in a directory:
pnpm --filter @radial/core debug -- \
index at://did:plc:example/com.disnetdev.radial.goal/example \
--dir ./fixtures \
--as-of 2026-07-18T00:00:00Z
Polling-v1 provenance and removal #
listRecords exposes a current URI/CID/value snapshot, not the commit
revision at which each record was originally written. Repository revisions
therefore serve as ingestion checkpoints only and never carry trust
decisions. Member removal revokes an exact addMember grant and retains
nothing: every record authored by a DID with no active grant is ignored,
identically, on every reader regardless of ingestion timing. Re-adding a
member is a new grant that restores trust in their records. The transport
seam allows Phase 7 CAR/Jetstream ingestion to replace snapshot-head
provenance with commit-level history.