# `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: 1. `--config `, or `$RADIAL_CONFIG` 2. the nearest `radial.json` in the working directory or any parent 3. `$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: 1. `--data-dir `, resolved against the working directory 2. `$RADIAL_DATA_DIR` 3. `"dataDir"` in this file, resolved against **the directory of this file** 4. `$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 ```json { "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" ] }, "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 ` 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:///.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](https://claude.com/claude-code) CLI) or `pi` (the [pi coding agent](https://pi.dev)). 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.` 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. | | `identifier` | A second agent account for this profile. Its app password is read from `$RADIAL__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. | | `stateDir` | `/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. | | `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](https://github.com/bluesky-social/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.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: ```sh 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 `dataDir` to an existing config moves the state directory** (unless `run.stateDir` is 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 set `run.stateDir` to 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: ```json { "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 `:` suffix — `openai/gpt-5.2`, `anthropic/claude-opus-4-8:high`, `openrouter/…`. `pi --list-models` shows what a provider offers. | | `codex` | [Codex CLI](https://developers.openai.com/codex) (`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: ```sh # 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: - **`claude`** reads `ANTHROPIC_API_KEY` or `CLAUDE_CODE_OAUTH_TOKEN`; either alone is enough. - **`pi`** reads 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](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/providers.md). - **`codex`** reads `CODEX_API_KEY`, else `CODEX_ACCESS_TOKEN` (a Business/Enterprise Codex access token) — its own resolution order, and either one alone is enough. Not `OPENAI_API_KEY`: current codex releases no longer accept it as authentication for `codex exec` (its auth chain reads the two names above and then a persisted `~/.codex/auth.json` that a turn container never has), so Radial does not count it as a codex credential. A codex profile on a daemon that holds only `OPENAI_API_KEY` is refused at startup, which is the point. Codex also never reaches the interactive `codex login` flow inside an ordinary turn. Codex profiles can instead use a refreshable ChatGPT account session: ```json "run": { "spaces": ["at://…"], "codexAuth": { "mode": "chatgpt-session" } } ``` Create a **fresh login dedicated to this Radial instance**: ```sh radiald codex login --config /path/to/radial.json ``` The device flow writes `/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`). ```json "run": { "spaces": ["at://…"], "modelEnv": ["CLOUDFLARE_API_KEY", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_GATEWAY_ID"] } ``` Three things to know about it: 1. **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_KEY` or `GH_TOKEN` in 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. 2. **Redaction is by name.** A forwarded value is scrubbed from streamed output and diagnostic logs when its name matches `KEY`, `TOKEN`, `SECRET`, `PASSWORD` or `CREDENTIAL` (case-insensitive) *and* the value is at least 8 characters. That is what keeps a forwarded `AWS_REGION=us-east-1` from blanking out every log line containing `us-east-1`. If you forward a secret under a name that does not match, rename it to `*_KEY`/`*_TOKEN`. 3. **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 ```json "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](https://docs.tangled.org/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: 1. **A push key.** `radiald init` generates one ed25519 key per agent DID, stores it 0600 under `stateDir/tangled-keys/`, and publishes the public half as an `sh.tangled.publicKey` record. It is idempotent: an existing key is adopted, never regenerated (regenerating would invalidate every collaborator grant you had already been given). 2. **A collaborator grant.** A knot authorises a push against the agent's DID, and only a repo owner can grant that. `radiald init` prints 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. 3. **Pinned host keys**, the `knownHosts` above. 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, and `StrictHostKeyChecking=no` would 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. ```sh 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 seven: `plan` and `implementation` (goal-scoped), plus the system types `architecture`, `architecture-inventory`, `glossary`, `conventions`, and `adr` (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: 1. a **registry record** in the space, naming the type; 2. a profile in this file whose `artifactTypes` includes that name — this is what the daemon dispatches on; 3. 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 ```sh # 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 (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. ### `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 its `subject`, asking an agent to reply to it in that goal's thread (§9, §10). Its terminal record is a `message`, 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".