A collaborative coding-agent orchestrator for atproto radl.app
radial docs running-an-agent.md
20 kB · 426 lines
Markdown
at main

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 use gh — 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.json in the working directory or any parent takes precedence over ~/.config/radial/radial.json; --config <path> or $RADIAL_CONFIG overrides both. Running two daemons on one machine is a matter of giving each config its own "dataDir".
  • artifactTypes names types from the space's registry. The registry is data: an admin adds security-review by 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 run warns about uncovered types at startup, once per space.
  • review and answer are built-ins, not registry data: radial type create refuses 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 overrides artifactTypes rather 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 pds explicitly 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/Enterprise CODEX_ACCESS_TOKEN) and restart; or
  • add "codexAuth": { "mode": "chatgpt-session" } to run, execute radiald 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.

  1. 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.
  2. Mint a ticket for the daemon's machine. radial space create --private prints the space URI, not a ticket; a member with a replica mints one with radial 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; restart radiald before the recipient syncs. A ticket is a secret — it reveals the space exists — so hand it over out of band, and point ticketFile at 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 own space.json carries the same facts.
  3. Run. radiald run does the rest at startup, in order: publishes a device key 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. list shows what the machine holds, publish is the idempotent publish run also does, rotate --did … mints a fresh key and retires the old one (hygiene — the fold never notices), and retire <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 export is the backup: every envelope, in the clear, and on you from there. One writer at a time, though: the radial CLI 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.