radial.json #
The operator's configuration for radiald: which agent identities it holds,
what each of them advertises, and which spaces the daemon serves. Agent
identities belong to the operator rather than to a checkout, so it normally
lives at ~/.config/radial/radial.json.
radiald config init writes a starting one. This document is what the fields
mean, and — the part that bites — how artifactTypes relates to the artifact
type registry that lives in the space rather than in this file.
Where it is read from #
In precedence order:
--config <path>, or$RADIAL_CONFIG- the nearest
radial.jsonin the working directory or any parent $RADIAL_CONFIG_DIR/radial.json, else$XDG_CONFIG_HOME/radial/radial.json, else~/.config/radial/radial.json
A radial.json beside a checkout is useful for testing or a second set of
identities. Sessions written by radiald init live separately, in the data
directory — the config never holds a credential.
The data directory #
Everything one daemon owns locally: sessions.json, and (unless run.stateDir
says otherwise) the run state directory holding the record index, checkpoints and
the turn/check/claim ledgers. In precedence order:
--data-dir <path>, resolved against the working directory$RADIAL_DATA_DIR"dataDir"in this file, resolved against the directory of this file$XDG_STATE_HOME/radial, else~/.local/state/radial
Rung 3 is config-relative on purpose: a working-directory-relative dataDir
would mean the same command run from two directories runs under two identities,
which is exactly the mistake this setting exists to prevent. radiald run prints
the directory it resolved and which rung chose it, and warns when
$RADIAL_DATA_DIR shadowed a different dataDir in the file.
Shape #
{
"identifier": "agent.example",
"pds": "https://pds.example",
"harness": "claude",
"models": ["claude-opus-4-8=high"],
"agents": {
"planner": {
"artifactTypes": [
"plan",
"architecture",
"architecture-inventory",
"glossary",
"conventions",
"adr",
"fdr"
]
},
"implementer": { "artifactTypes": ["implementation"] },
"reviewer": {
"models": ["claude-sonnet-4-5=high"],
"artifactTypes": ["review", "answer"]
}
},
"run": {
"spaces": ["at://did:plc:operator/com.disnetdev.radial.space/example"],
"forge": { "kind": "github" }
}
}
Top level #
| Field | Meaning |
|---|---|
identifier |
The agent account every profile uses unless it overrides identifier itself. A handle or a DID. Written as a DID it is checked: radiald run refuses to start when the stored session is a different identity, rather than running as whoever is in sessions.json. A handle only warns, since a handle can be renamed after radiald init. |
dataDir |
This instance's data directory (sessions, and by default the run state dir), resolved relative to this file. Naming it here is what lets --config <path> alone select one of several daemons on a machine — see "More than one daemon on one machine". |
pds |
Override for PDS resolution. Normally omitted: the PDS is resolved from the identity — a handle through its _atproto DNS TXT record (falling back to https://<handle>/.well-known/atproto-did), then the DID document's AtprotoPersonalDataServer endpoint. Set it against a local PDS in testing. |
harness |
Default agent harness: claude (the Claude Code CLI) or pi (the pi coding agent). Inherited by every profile that does not name its own. Validated at radiald init and again at radiald run startup — an unknown name is refused, never silently downgraded to another harness. |
models |
Default model preference order, "name=costHint" or { "name", "costHint" }. The turn hands these to the harness, which takes the first; an empty list lets the harness choose. The name reaches the harness's CLI verbatim — see "Choosing a harness and a provider" for each one's syntax. |
agents |
One entry per profile. Required. |
run |
The daemon's own settings. Only radiald run reads it. |
agents.<profile> #
The profile name is the record key its agent record is published under, so it
must be a legal atproto rkey (letters, digits, and .-_~:). One identity can
hold several profiles; radiald init publishes one agent record per profile,
which is what lets a single account offer distinct capability sets.
| Field | Meaning |
|---|---|
artifactTypes |
The type names this profile accepts: registry names, plus the built-ins review and answer. Required, and non-empty. See below — this is the field that has to agree with something outside this file. |
spaces |
Per-space overrides of artifactTypes, keyed by space URI. Optional. See "One profile, different capabilities per space". |
identifier |
A second agent account for this profile. Its app password is read from $RADIAL_<IDENTIFIER>_PASSWORD (e.g. RADIAL_OTHER_EXAMPLE_PASSWORD), not from $RADIAL_PASSWORD. |
pds, harness, models |
Per-profile overrides of the top-level defaults. Overriding harness is what lets one identity run, say, a pi reviewer beside a claude implementer — see "Choosing a harness and a provider". |
name |
The handle name published in the agent record. Defaults to the profile name. |
run #
Only spaces is required, and it may be empty in the file — the scaffold writes
[] so a fresh config still loads. radiald run is where an empty list is an
error. Everything else has a runtime default that is deliberately not frozen
into the file.
| Field | Default | Meaning |
|---|---|---|
spaces |
— | The at:// URIs of the spaces to poll. |
privateSpaces |
unset | Spaces whose records travel in signed envelopes instead of reaching a PDS (design §18). See below. radiald run binds one transport endpoint for the daemon, publishes it as each identity's deviceAddress, and serves every entry over it. |
stateDir |
<data directory>/run |
Records, checkpoints, turn/check/claim ledgers, per-turn directories and turn sockets. A relative path here is resolved against the working directory, not the config — radiald run warns when it sees one. Only one daemon may hold a state directory at a time; a second is refused at startup. |
image / checkImage |
radial-turn:latest / falls back to image |
Turn and check container images. |
concurrency / checkConcurrency |
1 / 1 | Turns and checks run on separate budgets, so a slow check never blocks a turn. |
timeoutMs / checkTimeoutMs |
60 min / 15 min | Whole-run wall clock caps, the check one covering its host-side checkout too. Outliving one is a retryable crash. |
cooldownMs / checkCooldownMs |
5 min / 5 min | How long a failed attempt waits before it is eligible again. |
retryBound / checkRetryBound |
3 / 3 | Attempts before the ledger gives up. radiald turn reset / check reset clears a row. |
forge |
unset | One forge: { "kind": "github" } or { "kind": "tangled", … }. Turns on merge observation and pull-request confirmation. Without a forge at all, a second implementation version can open a second pull request instead of updating the one under review — radiald run warns about this at startup. |
forges |
unset | The same setting as a list, for a space that holds projects on more than one forge. Each project is routed to an adapter by the host of its gitUrl. forge and forges are two spellings of one setting; keep one. |
mergePollIntervalMs / mergePollBackoffMaxMs |
60 s / 30 min | Per-PR merge polling base interval and backoff cap. |
privateAddressRefreshMs |
60 s | How often radiald run checks whether its private transport endpoint has moved — a machine changing network, a VPN coming up, a relay failover — and republishes deviceAddress when it has. The check is a local comparison and reaches a PDS only when the address actually changed, so this is a poll rate rather than a write rate. Inert without privateSpaces. |
network |
Docker's default bridge | Docker network for turn containers. Host and container: networking are refused. |
modelEnv |
[] |
Extra environment variable names forwarded from the daemon's own environment into every turn container, on top of what the loaded profiles' harnesses already declare. See "Provider credentials" below. |
codexAuth |
unset | { "mode": "chatgpt-session" } opts Codex profiles into a dedicated, refreshable ChatGPT login under the instance data directory. Run radiald codex login once. Conflicting CODEX_API_KEY / CODEX_ACCESS_TOKEN variables are then refused rather than allowed to override it. |
gitSchemes |
["https"] |
Git remote schemes a turn may clone from. |
memory |
4g floor |
Container memory limit (--memory syntax). Overrides the floor upward; never below it. |
turnTransport |
auto |
Selects Unix sockets on Linux and TCP on macOS/Windows. Explicit unix and tcp override detection; TCP is a development escape hatch—do not expose the daemon socket. |
claims |
see below | Claim and lease timing for open (unassigned) requests. Only matters in a space that uses them; a daemon serving assigned requests never writes a claim. |
jetstream |
unset | Opt-in Jetstream ingestion. Polling stays the authority and the backfill, so this only shortens latency. |
run.claims #
Claims exist so two operators' daemons can share one space's open requests without both answering the same one. A daemon writes a claim, waits until it has seen its own claim win the fold's tie-break, and only then starts work — so an unassigned request costs one extra ingestion cycle before it begins. Assigned requests are untouched by all of this.
| Field | Default | Meaning |
|---|---|---|
leaseMs |
10 min | How long a claim's lease runs, as a DURATION each observer measures from when it first saw that version of the claim (see docs/operators.md §3, "Claims and clocks"). A claim cannot be retracted, so this is also how long a crashed daemon's work sits before anybody else may take it — and how long a lapsed lease can look like live work in the UI. Hard ceiling: 1 hour, the protocol bound on a declared lease; above it every materializer, including this daemon's own, ignores the claim, so the value is refused at parse time rather than written and silently uncounted. |
renewIntervalMs |
leaseMs / 3 |
How often a held claim is renewed. At a third of the lease, two consecutive failed writes still leave a third attempt inside it. Refused at startup if it is more than a third of leaseMs. |
confirmCycles |
1 | Full ingestion cycles between writing a claim and acting on it. Raise it if claim-visibility latency between operators exceeds about two poll intervals; do not shorten the lease instead. |
maxOutstanding |
1 | Ceiling on simultaneously outstanding claims, counted per space — a daemon polling two spaces may hold this many in each. Also capped by the free turn concurrency across the whole daemon: claiming work this daemon cannot start is how a fast daemon starves a slow one while doing nothing with the work. |
radiald run refuses to start when leaseMs is less than four times the poll
interval (--interval): a lease that can lapse between two ingestion cycles
makes a daemon drop claims it is actively working on.
It also prints, and then watches, the clock-skew threshold it derives from the
lease — min(30s, leaseMs / 10) — warning when a peer's clock or a peer's
record timestamps are further from this machine's than that. Advisory only:
nothing in the fold compares two clocks.
run.privateSpaces #
A private space's records never reach a PDS: they travel as signed envelopes over
a private bus, and the space's at:// URI is a name rather than a location. That
is why this is a separate list from spaces and not a flag on one — a private
space needs the DIDs to bootstrap its device directory from, which no PDS can be
asked for. A URI in both lists is refused at parse time: a space has one mode,
fixed at creation.
| Field | Default | Meaning |
|---|---|---|
space |
— | The space's bare at:// URI. space create --private prints a locator (uri#cid); the fragment is the genesis pin, which comes from the ticket or the replica's space.json — a value with one here is refused at parse time rather than silently truncated. |
ticketFile |
unset | Path to a fresh transfer ticket (radial ticket mint) for provisioning a daemon from nothing. It must name active addressed peer hints; an old ticket without them is refused before the replica opens. A replica already created here by space create --private or ticket accept uses its space.json. A join bookmark only lists a private space and cannot substitute for this ticket. A secret — it reveals that the space exists — so it is a path rather than a value. |
bootstrapDids |
[] |
Extra DIDs whose public repos are polled for the device directory before membership is known. The ticket supplies the founder; add a second always-on replica's identity here. Each is disclosed publicly as a likely participant. |
What startup does with it (docs/adr-private-mode-iroh.md §20). For each
identity this instance runs, radiald run publishes a device record if it has
none; it then binds one transport endpoint for the whole daemon — its secret
kept in <data directory>/private/endpoint.key, so the identity survives a
restart — and publishes it as each identity's deviceAddress, rewritten only when
the endpoint or its relays actually changed. Each entry's replica is opened in
<data directory>/private/<founder-did>~<rkey>/, which is the same directory the
radial CLI uses: one machine, one replica, so radial private status describes
what the daemon is serving and radial private export backs it up. Do not run a
CLI command against it until the daemon is completely stopped — two writers
on one SQLite file, the same rule the state directory has. For a CLI grant or
ticket mint, use this same data directory: start radiald long enough to
publish an address and catch up, stop it completely, run the CLI command, then
restart radiald before the recipient syncs. The CLI and daemon must never
overlap on the replica.
A private space is only reachable while at least one peer is: radiald run on an
always-on node is what makes a laptop's replica — or a browser tab's — catch up.
(A tab binds a relay-only endpoint of its own when it opens a private space;
ADR §22.)
run.jetstream #
| Field | Default | Meaning |
|---|---|---|
endpoint |
— | A wss:// Jetstream subscribe endpoint, e.g. wss://jetstream2.us-east.bsky.network/subscribe. Required when the block is present. |
backfillIntervalMs |
60 s | How often to poll while the stream is healthy. Polling never stops; it just slows down. The moment the stream reports degraded or reconnects, the short --interval is back. |
A stream event wakes the run loop immediately rather than waiting out the
interval. Record deletes are counted and never applied — Radial has no
record tombstones (see docs/operators.md).
More than one daemon on one machine #
Two agent identities under one daemon needs none of this: profiles already carry
their own identifier, and one radiald run serves them all. What this section
is for is two daemons — separate processes, separate lifecycles, separate
GitHub accounts or provider keys.
Give each one a config that names its own data directory, and --config selects
it outright:
radiald config init agent-a.example --config ~/radial/a.json --data-dir ~/radial/a-state
radiald config init agent-b.example --config ~/radial/b.json --data-dir ~/radial/b-state
printf '%s\n' "$A_PASSWORD" | radiald init --config ~/radial/a.json --password-stdin
printf '%s\n' "$B_PASSWORD" | radiald init --config ~/radial/b.json --password-stdin
radiald run --config ~/radial/a.json &
radiald run --config ~/radial/b.json &
No environment variable appears anywhere above, which is the point: $RADIAL_CONFIG
and $RADIAL_DATA_DIR still work, and still take precedence, but forgetting one on
a single invocation is how an instance ends up reading the wrong sessions.json.
Profile names do not have to differ. Both instances may call a profile
planner: a profile name is a record key in the agent's own repo, and the two
repos are different. What must differ is the data directory — profiles are keyed
by name within a sessions.json, so two instances sharing one file would
overwrite each other's identity. radiald init refuses to do that (it names both
DIDs and the file; --replace-identity is the override), and radiald run refuses
to start when a configured DID does not match its stored session.
Per instance: the config, the data directory, sessions.json, the run state
directory and everything in it (record index, checkpoints, the three ledgers,
per-turn directories, tangled push keys), the container name prefix, and anything
taken from the process environment — GH_TOKEN, provider API keys, --interval.
Under systemd that is one Environment= block per unit.
Shared: the docker daemon and its images, and the gh CLI's own configuration.
gh auth token is per user, not per instance, so two daemons that must act as two
GitHub accounts each need their own GH_TOKEN exported in their own process.
Two things worth knowing before you split:
- Two daemons polling one space duplicate the ingestion traffic. That is inherent to running two of them, not a cost of separating their state.
- Adding
dataDirto an existing config moves the state directory (unlessrun.stateDiris set explicitly). The new one starts empty: a full re-ingest, and fresh ledgers, so attempts and cooldowns reset. Nothing is lost protocol-side — a fulfilled request is closed by the fold, not by the ledger — but if you want the old state, move it or setrun.stateDirto where it already is. The startup banner prints the resolved state directory, so the change is visible on the first run.
Choosing a harness and a provider #
Three harnesses ship today, and harness is resolved per profile, so one daemon can run all of
them at once — a pi reviewer on one provider's model and a claude implementer on another's is the
reviewer/implementer model diversity design §11 asks for, without a second daemon or a second
identity:
{
"identifier": "agent.example",
"harness": "claude",
"models": ["claude-opus-4-8=high"],
"agents": {
"implementer": { "artifactTypes": ["implementation"] },
"reviewer": {
"harness": "pi",
"models": ["openai/gpt-5.2=high"],
"artifactTypes": ["review", "answer"]
},
"second-reviewer": {
"harness": "codex",
"models": ["gpt-5.6-sol=high"],
"artifactTypes": ["review"]
}
}
}
| Harness | CLI | models entry syntax |
|---|---|---|
claude |
Claude Code (claude -p …) |
A model name the Claude CLI accepts, e.g. claude-opus-4-8. Anthropic only. |
pi |
pi coding agent (pi --mode json …) |
provider/model, with an optional :<thinking> suffix — openai/gpt-5.2, anthropic/claude-opus-4-8:high, openrouter/…. pi --list-models shows what a provider offers. |
codex |
Codex CLI (codex exec --json …) |
A bare model slug the Codex CLI accepts, e.g. gpt-5.6-sol. OpenAI's own models unless you configure another provider in codex's config. |
The name half of a models entry is passed to the CLI verbatim; the costHint half is an
operator annotation for humans reading the agent record and is never sent to any of the CLIs. Pi
carries its reasoning level inside the model pattern, so "anthropic/claude-opus-4-8:high=high"
says both things without Radial inventing a mapping between them. Codex does not: its reasoning
effort is a config key (model_reasoning_effort), which Radial does not set, so a codex profile
gets whatever the model's default effort is.
Switching an already-published profile takes two steps — the agent record is what an assignee menu reads, so editing this file alone changes nothing:
# 1. edit radial.json — set the profile's "harness" and "models"
# 2. republish that profile's agent record
radiald init reviewer --update
Provider credentials #
A turn container holds one model credential and (for implementation/review turns) a forge token, and nothing else (design §13). Which credential depends on the harness:
claudereadsANTHROPIC_API_KEYorCLAUDE_CODE_OAUTH_TOKEN; either alone is enough.pireads the environment variable its provider documents —OPENAI_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEY,GROQ_API_KEY,DEEPSEEK_API_KEY,XAI_API_KEY,MISTRAL_API_KEY,TOGETHER_API_KEY,FIREWORKS_API_KEY,CEREBRAS_API_KEY,NVIDIA_API_KEY,ZAI_API_KEY,AI_GATEWAY_API_KEY,ANTHROPIC_API_KEY, and the rest of pi's provider table.codexreadsCODEX_API_KEY, elseCODEX_ACCESS_TOKEN(a Business/Enterprise Codex access token) — its own resolution order, and either one alone is enough. NotOPENAI_API_KEY: current codex releases no longer accept it as authentication forcodex exec(its auth chain reads the two names above and then a persisted~/.codex/auth.jsonthat a turn container never has), so Radial does not count it as a codex credential. A codex profile on a daemon that holds onlyOPENAI_API_KEYis refused at startup, which is the point. Codex also never reaches the interactivecodex loginflow inside an ordinary turn.
Codex profiles can instead use a refreshable ChatGPT account session:
"run": {
"spaces": ["at://…"],
"codexAuth": { "mode": "chatgpt-session" }
}
Create a fresh login dedicated to this Radial instance:
radiald codex login --config /path/to/radial.json
The device flow writes <data directory>/codex-auth/auth.json. Radial never copies or mounts the
operator's ordinary ~/.codex: two copies descended from one login can race refresh-token rotation
and break each other. A turn gets a scratch CODEX_HOME containing only the validated auth file
and a daemon-authored credential-storage setting. Codex may refresh that scratch file; Radial
atomically validates and writes it back, then deletes the entire scratch home. No rollout, cache,
config, plugin or turn-created file survives. Turns sharing the login are serialized even when
run.concurrency is greater than one.
Managed account auth is a stronger credential than design §13's preferred spend-capped API key,
and the turn can read it while it runs. Use it only for workloads whose repository and prompt
inputs you trust. CODEX_API_KEY and CODEX_ACCESS_TOKEN are startup errors in this mode so an
ambient shell export cannot silently select a different billing path.
The daemon forwards a name only when it is set in its own environment, and never invents a
value. radiald run prints the names it will forward at startup (names only, never values):
loaded 3 agent profile(s): implementer (claude), reviewer (pi), second-reviewer (codex)
model credentials forwarded to turns: ANTHROPIC_API_KEY, OPENAI_API_KEY, CODEX_API_KEY
A profile whose harness has no credential available refuses at startup rather than failing one turn at a time — the daemon's environment does not change while it runs, so an authentication error per request would only burn a ledger attempt and a cooldown to say the same thing.
run.modelEnv extends that list with anything the harnesses do not enumerate: a provider Radial has
not heard of, or provider configuration that is not itself a credential
(CLOUDFLARE_ACCOUNT_ID, AZURE_OPENAI_BASE_URL, AWS_REGION, PI_CACHE_RETENTION).
"run": {
"spaces": ["at://…"],
"modelEnv": ["CLOUDFLARE_API_KEY", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID"]
}
Three things to know about it:
- It is an allowlist of names, never a prefix glob. Every entry is a credential the turn then
possesses, and a turn is a prompt-injection surface (design §13) —
AWS_SECRET_ACCESS_KEYorGH_TOKENin here hands the daemon operator's own credential to an agent. Name only what the model needs, and prefer a per-provider key dedicated to agent turns. - Redaction is by name. A forwarded value is scrubbed from streamed output and diagnostic logs
when its name matches
KEY,TOKEN,SECRET,PASSWORDorCREDENTIAL(case-insensitive) and the value is at least 8 characters. That is what keeps a forwardedAWS_REGION=us-east-1from blanking out every log line containingus-east-1. If you forward a secret under a name that does not match, rename it to*_KEY/*_TOKEN. - Spend capping is yours. Design §13's containment answer is "a spend-capped model key". Only you can set that cap, per provider — Radial cannot enforce one.
Pi's own knobs can ride in the same way. PI_OFFLINE=1 in run.modelEnv disables its startup
network calls entirely; the daemon already sets PI_SKIP_VERSION_CHECK=1 and PI_TELEMETRY=0 on
every pi turn but deliberately leaves the provider catalog refresh on, since that is how a
newly released model becomes selectable.
Radial pins --no-approve on every pi invocation: /work is an untrusted checkout, and pi's
project-trust default is a global setting an operator could have flipped, at which point a repo
shipping a .pi/settings.json, a project extension or a skill would execute code inside a container
holding a model key.
Codex gets the same treatment from the other direction. Every codex turn runs
codex exec --json --dangerously-bypass-approvals-and-sandbox --ephemeral --ignore-rules: the
container is the sandbox (codex's own Landlock/seccomp layer inside a hardened container is at best
redundant, and an approval request in exec mode is an error, not a prompt), sessions are not persisted
to a tmpfs $HOME, and the checkout's .codex/ execpolicy rules get no say in the run. A project's
.codex/config.toml and hooks are already ignored — codex loads those only for a project marked
trusted in $CODEX_HOME/config.toml, which a fresh per-turn $HOME never is.
--ephemeral has one cost beyond the RAM it saves, worth knowing before you debug a quiet codex
log: codex recovers items it dropped under internal backpressure by re-reading the session rollout
at the end of a turn, and with no rollout there is nothing to re-read, so a turn's --json stream
can omit items — up to and including the final assistant message — that the turn really did
produce. Turn outcome is unaffected: a turn is classified from what the harness submitted over
the sidecar socket, never from what it printed.
The tangled forge #
"run": {
"spaces": ["at://did:plc:operator/com.disnetdev.radial.space/example"],
"forges": [
{ "kind": "github" },
{
"kind": "tangled",
"hosts": ["tangled.org"],
"api": "https://api.tangled.org",
"knownHosts": ["tangled.org ssh-ed25519 AAAAC3Nza..."]
}
]
}
| Field | Default | Meaning |
|---|---|---|
hosts |
["tangled.org"] |
The appview/knot hosts this adapter speaks for. A self-hosted knot adds its own; the default list is replaced, not extended. |
api |
"https://api.tangled.org" |
The Bobbin instance pull state is read from — tangled's read-only, unauthenticated XRPC appview. Point it at your own if you host one. false turns it off, and the daemon folds sh.tangled.repo.pull.status records off PDSes itself: the pull's author and the repository's owner, both ordinary accounts with ordinary PDSes. That is a working deployment, with one honest limit — a merge performed by a third-party collaborator lands in their repo, which only an appview folds. The same fold also runs whenever the appview is unreachable, has not indexed a pull yet, or reports open (which means "no ruling in my index", not "nobody ruled"), so leaving api unset costs nothing. |
pageLinks |
true |
Read the web appview to verify numbered pull-page links. A number is published only when its page names the exact pull-record at-uri. false avoids HTML reads entirely: artifacts carry at-uris and existing numbered links are not resolvable. |
knownHosts |
unset | ssh-keyscan output for those hosts, pinned. Required before an implementation turn can push. Radial never falls back to StrictHostKeyChecking=no — an unpinned push is a MITM-able push. Observation alone needs none of this. |
Three things a tangled project needs that a GitHub one does not:
- A push key.
radiald initgenerates one ed25519 key per agent DID, stores it 0600 understateDir/tangled-keys/, and publishes the public half as ansh.tangled.publicKeyrecord. It is idempotent: an existing key is adopted, never regenerated (regenerating would invalidate every collaborator grant you had already been given). - A collaborator grant. A knot authorises a push against the agent's DID, and only a repo owner
can grant that.
radiald initprints the exact line to act on. Until it is granted, the turn's push fails and the agent posts a question on the request rather than working around it. - Pinned host keys, the
knownHostsabove. A GitHub turn pushes over HTTPS with a token; a tangled turn pushes over ssh, so git has to decide whether the knot answering on port 22 is the knot. There is no human at a container's terminal to answer a trust-on-first-use prompt, andStrictHostKeyChecking=nowould accept whatever answered — on a forge where the pushed branch is the thing the artifact record commits to, an intercepted push silently corrupts what the agent signed. So the operator pins the keys and Radial refuses to guess.
Producing knownHosts #
Scan every host in hosts (a self-hosted knot included — that list is replaced, not extended, so
each entry needs its own lines) and check the fingerprints against the ones the host publishes
out-of-band before you trust them. A scan is unauthenticated: pinning unverified output pins
whatever answered, which is the attack the pin exists to stop.
ssh-keyscan tangled.org # the lines to paste
ssh-keyscan tangled.org | ssh-keygen -lf - # their fingerprints, to compare
Paste each output line verbatim as one string in the array — it is ordinary known_hosts format,
and Radial writes the lines into /run/radial-forge/known_hosts unchanged (blank entries are
dropped). Restart the daemon; the startup warning about a tangled forge with no knownHosts is how
you know it took.
The project's gitUrl is the browse/clone URL (https://tangled.org/@owner/repo); the ssh push
remote is derived from it and handed to the turn as $RADIAL_PUSH_REMOTE. On this forge the daemon
opens the pull request itself — tangled ships no CLI a container could use — so links.pr carries
a direct web-appview page only after that page proves it names the exact pull-record at-uri. Any
miss or appview failure carries the stable at:// URI instead (design §10,
docs/adr-tangled-forge.md).
artifactTypes and the registry #
This is the one field whose correct value is not knowable from this file.
Artifact types are per-space registry data, not lexicon (design §4). A space
admin writes an artifactType record and every client grows a button for it — no
deploy, no schema change. radial space create seeds eight: plan and
implementation (goal-scoped), plus the system types architecture,
architecture-inventory, glossary, conventions, adr, and fdr
(project-scoped, §8), whose current versions ride in every turn's bundle.
What no client can grow is an agent. Three things have to line up:
- a registry record in the space, naming the type;
- a profile in this file whose
artifactTypesincludes that name — this is what the daemon dispatches on; - a published agent record listing it — this is what a human's assignee menu
and auto-review read, and it is written by
radiald init, not by editing this file.
Miss the second or third and a request for that type is written and then sits: nobody can be assigned, and it looks exactly like a request whose agent is busy.
radiald run names both gaps at startup, once per space, after its first sync:
⚠️ at://…/space1: the registry has types no agent in the space accepts —
conventions, adr. A request for one has no agent to assign and will sit open.
Add them to a profile's "artifactTypes" in ~/.config/radial/radial.json,
then run: radiald init planner --update
⚠️ profile "planner" publishes plan, architecture but ~/.config/radial/radial.json
says plan, architecture, adr — the published record is what an assignee menu
offers, so run: radiald init planner --update
An agent run by another operator counts as coverage: the daemon reads what the space's agent records say, not only its own.
Adding a type to a profile #
# 1. edit radial.json — add the name to a profile's artifactTypes
# 2. republish that profile's agent record
radiald init planner --update
✓ planner did:plc:… plan, architecture, architecture-inventory, glossary,
conventions, adr, fdr (republished)
radiald init prints what each profile now publishes, so the list is visible
without reading the repo. Re-running it is safe: a profile whose published
record already matches is left alone.
Without --update, a profile whose capabilities have changed is refused
rather than republished — the guard is what stops a second config, or a stale
checkout, from quietly rewriting what a running agent advertises. The refusal
names what moved:
✗ planner Agent profile planner is already published with different capabilities:
artifactTypes: published plan, config plan, adr (adds adr)
Re-run with --update to republish it from this config.
--update rewrites the record under the same key, so the profile keeps its
identity and every request already assigned to it still resolves. A republish is
one of the protocol's three sanctioned in-place edits (design §4): a daemon or a
browser that already holds the older version adopts the new one on its next sync,
latest revision wins, and it is not flagged as an edit. Nothing needs clearing —
if a daemon keeps warning about drift after a republish, it is running a build
from before that rule, and radiald index reset (with the daemon stopped) is the
workaround.
One profile, different capabilities per space #
A daemon commonly serves several spaces with one set of profiles, and the same
profile is not always wanted for the same work in each. spaces says so:
"agents": {
"planner": {
"artifactTypes": ["plan", "adr"],
"spaces": {
"at://did:plc:…/com.disnetdev.radial.space/abc": { "artifactTypes": ["plan"] },
"at://did:plc:…/com.disnetdev.radial.space/xyz": { "artifactTypes": [] }
}
}
}
An entry is an override, not an addition: in that space, its artifactTypes
is the whole answer. An empty list is legal and means "this profile does nothing
here" — the one thing artifactTypes cannot say, since it must be non-empty. A
space with no entry gets artifactTypes unchanged, which is every config written
before this existed.
It gates everything the daemon does per space — dispatch, claiming, auto-review,
merge annotations — and it is published, so a human's assignee menu narrows with
it. radiald init <profile> --update writes the overrides into the agent record
as scopes, and treats a changed one as drift like any other field:
✗ planner Agent profile planner is already published with different capabilities:
space at://…/space/abc: published plan, adr, config plan (drops adr)
Re-run with --update to republish it from this config.
Two things are worth knowing before you use it:
- A scope naming a space this instance does not serve is a warning, not an error. A second instance sharing this config may serve it. But a URI with a typo in it is a narrowing that will never take effect, so the daemon says so at startup.
- A private space's scope is never published. A private space's fold reads
agent records only from the private bus, so
radiald initcannot give one an assignee list at all:--updatewrites to a PDS, which that fold never reads. The running daemon publishes them into the replica itself, resolved to that space's effective list — so for a private space the sequence is editradial.json, restartradiald run, with noinit --updatestep and nothing to republish by hand. (This is also why the coverage warning below reads differently for a private space.)
review and answer are not registry types #
Two type names are built-ins of the turn layer rather than registry data, and
radial type create refuses both:
review— auto-review (§7) writes the request and the daemon composes the brief itself, so no registry record is needed. Its terminal record is a verdict on the named subject.answer— a human-authored request naming one message version as itssubject, asking an agent to reply to it in that goal's thread (§9, §10). Its terminal record is amessage, not an artifact. The request must be goal-scoped and must name an assignee; the daemon never authors one.
A profile still has to list a name in its artifactTypes to be offered that
work, which is why the scaffold ships a reviewer profile carrying both. They
ride together because they are the same register of work — reading what is
already there and saying something about it — and because neither produces an
artifact or a row in a goal's unit list; splitting them across profiles is a
routing decision, not a structural one.
Because neither is in the registry, neither ever appears in the coverage warning
above: a space with nobody publishing answer gets no startup warning and no
error, just an empty picker behind the UI's "Ask an agent to reply".