Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ariel

Ariel is the chat bridge for the Caliban agent fleet. It brings fleet notifications, commands, approvals, and conversation into the chat platforms where teams already work: Discord first, then Slack and Microsoft Teams.

In The Tempest, Ariel is the spirit Prospero sends to carry messages. Here it carries them between people in chat and the fleet that Prospero runs.

Where it stands

Ariel is pre-1.0. The current release is v0.3.0, published as the container image ghcr.io/caliban-ai/ariel:0.3.0 for linux/amd64 and linux/arm64. It is a running bridge on Discord:

  • arield watches prosperod's fleet and keeps one live message per agent in every chat channel configured to follow that agent's workspace, editing it in place as the agent changes, collapsing bursts into a summary and pacing sends against the platform's own budget — and, for an agent started from chat, naming the person who asked for it (Who started an agent);
  • it answers eight /ariel commands — link, status, spawn, kill, respawn, channel, configure and invite — each authorized on the lower of the person's role and the channel's ceiling, each mutating one audited in gonzalo (Chat Commands);
  • identity, role grants, channel configuration, link tokens and the audit trail are gonzalo records; Ariel stores nothing of its own;
  • channel configuration is re-read while the daemon runs, so adding or retiring a channel needs no restart;
  • the ariel CLI configures channels and mints link tokens (The ariel CLI).

Not built: the approvals layer and conversational threads are designed and deferred, and the Slack and Teams backends are not written. See Status & Roadmap for exactly what is built, what is next, and what is blocked.

Reading this guide

Architecture & Couplings

Ariel is a standalone service, a sibling to prospero and gonzalo, so that chat platform SDKs, credentials, and rate limiting stay behind one boundary (ADR 0002).

The shape

Everything in the diagram exists in code. The session bridge that would make a chat thread an agent session does not, and neither do the Slack and Teams backends; see Status & Roadmap.

Discord                                   (Slack / Teams: not written)
   │  Gateway socket (interactions in), REST (messages out)
   ▼
┌──────────────────────────────────────────────────────────┐
│ arield                                                   │
│  ChatProvider trait · notifier · command router ·        │
│  two-key authorization · renderer · prospero client ·    │
│  fleet watcher · gonzalo records client                  │
└──────────┬───────────────────────────────┬───────────────┘
           │ HTTP + SSE                    │ records
           ▼                               ▼
       prosperod                        gonzalod
  fleet snapshot, per-agent       people, bindings, roles,
  streams, spawn, kill, input     channel config, link
                                  tokens, audit

Couplings

  • Through prospero. All fleet control and fleet events go over prosperod's public HTTP and SSE API. Ariel takes no dependency on prospero crates: it mirrors the wire types it reads in ariel_core::prospero::types, and golden fixtures written from prospero v0.8.1 pin them, with v0.9's on_behalf_of on the event envelope pinned by its own wire tests (ADR 0005). Every enum Ariel matches on has an Unknown fallback, so a newer prosperod does not break decoding, and a field a newer prosperod adds is optional, so an older one still decodes. Ariel authenticates with a bearer token from ARIEL_PROSPERO_TOKEN_FILE, which prosperod requires from v0.8 with API authentication on (ADR 0013).
  • With gonzalo. Ariel stores nothing of its own. Identity, role grants, channel configuration, link tokens, and the audit trail are gonzalo records (ADR 0003), read and written through ariel_core::records. The record kinds are gonzalo's own, defined by its ADR 0022 and ADR 0023 and shipped from gonzalo 0.7.0 (gonzalo compatibility).
  • To caliban only through prospero. Ariel never talks to caliband.

Talking to prosperod

ProsperoClient (in ariel-core) maps prosperod's routes one to one:

MethodRouteClient call
GET/api/sessionsession() — who prosperod thinks Ariel is, and with which scopes
GET/api/fleetfleet()
POST/api/workspaces/{workspace}/agentsspawn()
POST/api/agents/{id}/killkill()
POST/api/agents/{id}/respawnrespawn()
POST/api/agents/{id}/inputinput()
POST/api/agents/{id}/end-inputend_input()
GET/api/agents/{id}/stream?from={seq}stream() (SSE)
GET/api/fleet/stream?from={cursor|now}fleet_stream() (SSE)

A base URL with a path prefix, as behind a reverse proxy, is kept. Requests time out after 30 seconds (the event stream excepted), and connecting after 5. Both http and https work: TLS goes through rustls and verifies against the platform's trust store, which the runtime image populates. In-cluster traffic to a Service stays plain http; https matters for reaching a prosperod through an ingress. arield calls session() at startup whenever ARIEL_PROSPERO_URL is set, so a refused or missing token is an error in the log rather than a surprise at the first command.

on_behalf_of(person) returns a clone of the client that adds prospero v0.9's X-Prospero-On-Behalf-Of header to every request it makes. Ariel holds one operate token for a whole chat workspace, so the token alone cannot tell one person's agents from another's; the person id travels beside it and comes back on the events those requests emit. A value prosperod would answer 400 to is dropped rather than failing the request it rode on — a gonzalo person id never is one, but a rejected header would fail the whole command.

FleetWatcher holds one connection to GET /api/fleet/stream, prospero v0.9's fleet-wide stream, and forwards every agent's events from it (#55). Each event's SSE id: is a fleet cursor — durable insertion order across all streams, which is the only total order a fleet has, since a per-agent seq cannot order two different agents. A dropped connection reconnects with ?from=<cursor>, which resumes strictly after it: no gap, no duplicate.

The first connection asks for ?from=now, so a restarted daemon announces nothing that happened while it was down (ADR 0011). The cursor lives in memory only — Ariel keeps no state of its own (ADR 0003) — so a restart begins at now again rather than resuming. Because the stream is read from prosperod's durable store rather than its live bus, there is no slow-consumer gap signal to handle, unlike the per-agent stream.

Before prospero v0.9 there was no such endpoint, and the watcher built the same feed by polling GET /api/fleet every 5 s and opening one SSE stream per agent. That cost a connection per agent, and it missed any agent that started and finished between two polls — the channel never heard about it at all. fleet() is still how /ariel status reads a snapshot; it is no longer how Ariel learns that something happened. stream() remains on the client, since it mirrors a route prosperod still serves, but nothing in Ariel listens to it.

Crates

CrateBinaryRole
ariel-core—Provider-neutral core: the ChatProvider trait and ConsoleProvider, the prospero client and fleet watcher, the renderer, the notifier, the command handlers, two-key authorization, account linking, channel parsing, and the gonzalo records client. Never depends on a chat platform SDK; CI checks this.
ariel-discord—Discord backend on twilight (ADR 0010).
ariel-daemonarieldThe long-running bridge: configuration, logging, the health endpoint, and the wiring that joins the fleet watcher, the per-channel notifiers and the command router.
ariel-cliarielThe operator CLI: ariel channel show/set and ariel link new (The ariel CLI).
ariel-e2e—Nothing ships from it: it holds the headless end-to-end smoke test, which runs the bridge against prosperod's and gonzalod's own server code. Kept apart so prospero and gonzalo-server stay out of every other crate's dependency tree, and excluded from the main CI gate, which runs it in its own job.

ariel-core's modules map onto those jobs: chat (the provider boundary), prospero, records (gonzalo), render, notify, commands, auth, link and channels.

Chat providers and features

Every platform sits behind one ChatProvider trait (ADR 0004, ADR 0006): a small required core (id, capabilities, register_commands, post, inbound) plus capability-gated methods (edit, direct_message, start_thread) that default to Unsupported. A Capabilities descriptor, including message size limits and a per-channel send budget, tells the core which it may call.

Each backend is its own crate, consumed by ariel-daemon as an optional dependency behind a feature of the same name:

CrateFeatureDefaultNotes
ariel-daemondiscordyesCompiles in ariel-discord.
ariel-corecontract-testsnoExposes the shared provider contract suite for backends' tests.

Features are additive: they decide which providers can exist in a build, and configuration decides which one runs — today, whether the Discord token, guild ID and application ID are all set. arield logs the providers compiled into it at startup, on the arield starting line. cargo build -p ariel-daemon --no-default-features builds with no chat backend, and CI keeps that build green.

Rendering and notifications

Ariel's notification design is one live message per agent, edited in place, with bursts of five or more spawns collapsed into one summary message, and sends paced per channel in the core (ADR 0007). Both halves exist.

AgentView folds agent_spawned, status_changed, agent_finished, and agent_gone events into an agent's current state and reports whether its message changed; render_agent and render_summary turn that state into provider-neutral messages with a severity, fields for the start time, who started it, the end time, outcome, cost and turns, and an optional dashboard link. The attribution belongs to the spawn: a later event carrying someone else — a kill, say — does not rewrite who started the agent.

Notifier then holds one per channel. It decides for itself whether it follows an event's workspace and whether the channel's notify preset wants that event kind (ADR 0009), keeps the live message's id so a change is an edit rather than a new post, collapses a burst, and paces its sends against the provider's advertised SendBudget. A rate limit, a lost permission or a message somebody deleted is handled rather than fatal. arield starts one notifier per configured channel and feeds every fleet event to all of them, so adding a channel is a record, not a code change.

What arrives on an event is the gonzalo person id Ariel asserted, not a display name: ids are stable and unique, names change and collide. So the notifier resolves it before the channel sees it, reading that person's record once and remembering the answer. A failed or unknown lookup is not cached — the person may be created later — and the id is shown as it came rather than dropped, which still tells two people's agents apart.

Commands and authorization

A command arrives as an Inbound::Command from the provider and is answered by ariel_core::commands. Two keys decide what it may do: the role the person holds, from their gonzalo grants, and the ceiling on the channel's configuration record. The command runs at the lower of the two (ADR 0009, ADR 0012), and every command that changes something leaves exactly one audit entry in gonzalo, Denied or Succeeded/Failed. An unlinked chat account gets nothing but instructions for linking, and an unconfigured channel is refused before prosperod is asked anything, so it cannot be used to discover which agents exist. The details, and the table of who may run what, are in Chat Commands.

An allowed decision carries the person it is for, and spawn, kill and respawn run against a client clone that names them to prosperod, so the fleet events a command causes are attributed to the person rather than only to Ariel's token (Who started an agent). Prosperod does not verify the claim — it cannot authenticate someone else's user — so the token stays the authenticated identity and a false claim stays attributable to the credential that made it.

/ariel link is the one command that works in any channel: it redeems a one-time token minted by ariel link new or /ariel invite, binds the chat account to a person and grants the token's role. Only a hash of the token is stored, and it is marked used rather than deleted, so a sync between gonzalo stores cannot bring it back.

Security and deployment

ADR 0008 sets the deployment model: credentials only as mounted Secret files, a single-replica Deployment with no ingress (Discord events arrive over an outbound Gateway connection), and a read-only root filesystem. ADR 0013 revisits its assumption that prosperod is unauthenticated: from prospero v0.8 Ariel presents a scoped API token, so the NetworkPolicy fencing prosperod is defence in depth rather than the only control.

The file-based credentials, health endpoint, logging and image are implemented. The cluster rollout is the ariel chart in caliban-ai/helm-charts, which tracks the published image.

Status & Roadmap

Ariel is pre-1.0. The current release is v0.3.0, published as ghcr.io/caliban-ai/ariel:0.3.0 for linux/amd64 and linux/arm64; see the changelog. This page lists what the code does today, what is next, and what is deferred. The MVP walking skeleton, caliban-ai/ariel#22, is complete; remaining work is tracked on the caliban-ai board.

Capability layers

Ariel is designed as one bridge at four depths, each shippable on its own.

LayerDirectionWhat it doesState
NotificationsoutAgent started, changed status, finishedBuilt. arield watches the fleet and posts a live message per agent to every channel configured to follow its workspace, naming the person who asked for it.
ChatOpsbothSlash commands to list, spawn, kill, and restart agentsBuilt. Eight commands: link, status, spawn, kill, respawn, channel, configure, invite (Chat Commands).
Approvalsboth, narrowApprove or deny a risky action with buttonsDesigned, deferred (#6). Waiting on a permission-request event from caliban and prospero.
Conversationalboth, fullA chat thread is an agent sessionDesigned, deferred (#7).

Commands are authorized on two keys: a person's role and a ceiling set on the channel, the lower of the two winning (ADR 0009). The authorization and its audit trail are built (ADR 0012), and every command but /ariel link goes through them.

Built

ariel-core

  • ChatProvider trait, addressing types (ChannelRef, UserRef, ThreadRef, MessageRef), the provider-neutral Message, CommandSpec and Role, Capabilities with Limits and SendBudget, and ProviderError (ADR 0006).
  • ConsoleProvider: an in-memory provider that records every outbound call and accepts injected commands, with configurable capabilities.
  • The shared provider contract suite, behind the contract-tests feature, run against both ConsoleProvider and the Discord backend.
  • ProsperoClient for prosperod's fleet, spawn, kill, respawn, input, end-input and stream routes; an incremental SSE decoder with gap handling; and FleetWatcher, which holds prospero v0.9's fleet-wide stream (GET /api/fleet/stream) open and forwards every agent's events from it, resuming after the last fleet cursor when a connection drops (#55).
  • Mirrored prospero wire types pinned by golden fixtures from prospero v0.8.1, plus v0.9's on_behalf_of on the event envelope, pinned by its own wire tests (ADR 0005). Bearer-token authentication to prosperod, sent on every request including the event stream (ADR 0013). Both http and https work, TLS through rustls against the platform's trust store.
  • ProsperoClient::on_behalf_of, which names the person a request acts for in prospero v0.9's X-Prospero-On-Behalf-Of header. A value prosperod would answer 400 to — blank, over 128 characters, or carrying control characters — is dropped rather than failing the request it rode on.
  • The renderer: AgentView, render_agent, render_summary. An agent's message carries a started by field when the spawning client named a person (Who started an agent); one started outside Ariel renders exactly as it did before.
  • The notifier: one live message per agent, edited in place; burst summaries; routing by what a channel follows and its notify preset; and sends paced against the provider's own budget, with rate limits, lost access and deleted messages handled (ADR 0007, ADR 0009). It also resolves the person id an event carries to that person's display name, reading gonzalo once per person and not caching a miss, so someone created later still resolves.
  • Records, the gonzalo client for access-control records: typed create, read, update, delete and list for people, identity bindings, role grants, channel configuration and link tokens, and append-only audit entries, over gonzalod (ServerStore) or a local FsStore. A lost write race is an ordinary Write::Conflict carrying the winning record (gonzalo compatibility).

ariel-discord (ADR 0010)

  • Registers one guild /ariel command with a subcommand per CommandSpec (string options).
  • Posts messages as embeds colored by severity; edits them; opens direct messages; starts threads from a message.
  • Reads the Gateway with no privileged intents and turns /ariel interactions into Inbound::Commands.
  • Answers interactions, auto-deferring after 2 seconds to meet Discord's 3-second deadline, and supports ephemeral (private) replies.
  • Advertises every capability except buttons and reading thread replies.
  • Verified by the contract suite against a stub of Discord's REST API, plus a manual smoke test against a real guild.

arield

  • Reads its configuration from the environment, failing at startup on an unreadable or empty credential file, a bad address, a non-URL dashboard or a non-numeric Discord ID (Configuration).
  • Connects to prosperod, gonzalod and Discord, reads the channel configuration records belonging to the running provider, and notifies each channel that follows an event's workspace. Without those settings it serves health only.
  • Re-reads the channel records while running (ARIEL_CHANNEL_RELOAD_SECS, 60s by default), so a channel added, retired or re-scoped takes effect without a restart. A failed read keeps the channels already served rather than dropping them.
  • Registers every /ariel command: link, status, spawn, kill, respawn, channel, configure and invite. Link answers privately, linking the account and granting the token's role; the rest are authorized on two keys, and every command that changes something is audited (Chat Commands). spawn, kill and respawn tell prosperod which person they act for, so the events they cause name that person and not only Ariel's token.
  • Does not replay what it missed across a restart (ADR 0011).
  • Writes its log to stderr, so kubectl logs shows a refused token or a throttled bot: RUST_LOG sets the level and ARIEL_LOG_FORMAT=json switches to one JSON object per line. Logging is installed before anything else can fail, and the startup line names the version and the chat providers compiled into the build.
  • Checks its prosperod token as soon as ARIEL_PROSPERO_URL is set, in the background so a slow prosperod never holds up the health endpoint.
  • Serves GET /healthz and shuts down cleanly on Ctrl-C or SIGTERM.

ariel (CLI)

  • ariel channel show and ariel channel set manage a channel's configuration record: what it follows, how much it hears and its command ceiling (The ariel CLI). Changes are audited, and a concurrent edit is reported as a conflict instead of overwriting what is stored.
  • ariel link new mints a one-time link token that grants a role when redeemed in chat with /ariel link; only its hash is stored, and it works once (The ariel CLI).
  • Acts on gonzalod, or on a local gonzalo store with --store.

Build and release

  • CI: cargo fmt --check, clippy with -D warnings, build, test, a --no-default-features build of ariel-daemon, a check that ariel-core pulls in no chat SDK, and an 85% line-coverage floor (scripts/coverage.sh). A docs-only change skips the Rust jobs.
  • A headless end-to-end smoke test (crates/e2e), run in its own CI job: the bridge against prosperod's (v0.9.0) and gonzalod's own server code, both with token authentication on, and prospero's fake caliban standing in for agents. A person links, spawns an agent from chat, sees its notification through to the finish — including the started by field resolved to the display name linking gave their account — and asks for the fleet status; the audit trail is checked in gonzalo. No network beyond loopback and no model API keys.
  • Dockerfile and a release workflow that builds ghcr.io/caliban-ai/ariel for linux/amd64 and linux/arm64 on native runners (no QEMU), validating on pull requests and pushing a multi-arch manifest on v* tags, tagged by version, by sha-<commit>, and latest.
  • Deployment: the ariel chart in caliban-ai/helm-charts, tracking the published image.

Next

The MVP walking skeleton (#22) is complete: the path from link through spawn, notification and status runs in CI against prosperod's and gonzalod's own server code, and was confirmed by hand in a real Discord guild against the home cluster at v0.2.0. What is open is refinement rather than foundation:

  • Require the end-to-end smoke job as a status check (#61).
  • Housekeeping: pin the Rust toolchain (#68) and remove the tracked .profraw files (#69).

Not yet decided or built, and not scheduled: the Slack and Teams backends, and core message fallbacks (truncation, dropping actions when a platform has no buttons).

Deferred by design

  • Approvals (#6): designed, and waiting on a permission-request event from caliban and prospero. Until one exists there is nothing for Ariel to ask about. Discord's buttons are the one provider capability ariel-discord does not yet advertise.
  • Conversational threads (#7): designed — a chat thread as an agent session — and deliberately after the first three layers. Reading thread replies is the other capability the Discord backend does not advertise yet.

Operating requirements

Not blockers in Ariel, but things the deployment has to supply:

  • gonzalod 0.7.0 or newer, with authentication on. The access-control record kinds ship from 0.7.0 (gonzalo ADR 0022 and 0023); 0.6.0 and earlier cannot decode them. Linking writes identity records, so a gonzalod with auth off would let any pod grant itself a role (ADR 0008, gonzalo compatibility).
  • A prosperod API token once prospero v0.8 or newer runs with API authentication on, with the operate scope for the commands that act (ADR 0013).
  • prosperod v0.9 or newer to see who started an agent. The attribution rides on a header v0.9 introduced; an older daemon ignores it and the started by field simply does not appear. Nothing else degrades (Who started an agent).

The gonzalo work Ariel's identity layer waited on — gonzalo#277 and #278 — landed in gonzalo v0.7.0, and Ariel depends on the published crate.

Getting Started

Ariel is a running bridge: arield watches prosperod's fleet, notifies the chat channels configured to follow it, and answers /ariel commands (Status & Roadmap). This page covers building and testing it from source, running it from the published image, and the three steps that turn a bare daemon into a bridge somebody can use.

Prerequisites

To run arield, nothing but the image and:

  • a prosperod (prospero v0.8 or newer if its API authentication is on; v0.9 or newer for an agent's message to say who started it) and a gonzalod 0.7.0 or newer, the first release carrying the access-control record kinds Ariel stores (Configuration);
  • a Discord bot in a guild (Discord Setup).

To build it:

  • A Rust toolchain with edition 2024 support. The container build uses Rust 1.95.
  • For the coverage gate only: cargo-llvm-cov and the LLVM tools (see scripts/coverage.sh).

Build and test

git clone https://github.com/caliban-ai/ariel
cd ariel

cargo build --workspace --exclude ariel-e2e    # arield (with Discord) and ariel
cargo test --workspace --exclude ariel-e2e     # unit, contract and golden-fixture tests

ariel-e2e is excluded because its dev-dependencies come from prospero's git repository; CI runs it as a separate job for the same reason. The full gate CI runs:

cargo fmt --all -- --check
cargo clippy --workspace --exclude ariel-e2e --all-targets -- -D warnings
cargo build --workspace --exclude ariel-e2e --all-targets
cargo test --workspace --exclude ariel-e2e

# a build with no chat backend must also pass
cargo build -p ariel-daemon --no-default-features
cargo clippy -p ariel-daemon --all-targets --no-default-features -- -D warnings
cargo test -p ariel-daemon --no-default-features

scripts/coverage.sh                # 85% line-coverage floor

# the end-to-end smoke, in its own job
cargo test -p ariel-e2e

No test in the main gate needs network access, a Discord token, or a running prosperod: the prospero client is tested against a local stub server, and the Discord backend against a stub of Discord's REST API. The end-to-end smoke test needs the network only to fetch prospero's crates; once built it runs prosperod's and gonzalod's own server code in-process over loopback, with prospero's fake caliban standing in for agents, and needs no model API key.

Run arield

cargo run -p ariel-daemon --bin arield

With no environment set, it logs what it is and serves health checks on 0.0.0.0:8081, but bridges nothing:

2026-10-03T21:28:21.750483Z  INFO arield: arield starting version="0.3.0" providers=["discord"]
2026-10-03T21:28:21.750817Z  INFO arield: serving health checks addr=0.0.0.0:8081
2026-10-03T21:28:21.750840Z  WARN arield: ARIEL_PROSPERO_URL and ARIEL_GONZALO_URL are not both set; serving health only
curl http://127.0.0.1:8081/healthz     # ok

arield takes no options but --help and --version; everything else is environment. It stops on Ctrl-C or SIGTERM, logging shutting down and exiting zero.

The log goes to stderr, so kubectl logs and a local terminal both show it. RUST_LOG sets the level and ARIEL_LOG_FORMAT=json switches to one JSON object per line. arield stops on Ctrl-C or SIGTERM. See Configuration for everything it reads.

To make it an actual bridge, give it a fleet, a record store and a chat provider:

export ARIEL_PROSPERO_URL=http://127.0.0.1:7878
export ARIEL_GONZALO_URL=http://127.0.0.1:8080
export ARIEL_GONZALO_TOKEN_FILE=./secrets/gonzalo-token
export ARIEL_PROSPERO_TOKEN_FILE=./secrets/prospero-token
export ARIEL_DISCORD_TOKEN_FILE=./secrets/discord-token
export ARIEL_DISCORD_GUILD_ID=123456789
export ARIEL_DISCORD_APPLICATION_ID=987654321

cargo run -p ariel-daemon --bin arield

Container

The released image is ghcr.io/caliban-ai/ariel, built for linux/amd64 and linux/arm64 on every v* tag and tagged by version, by sha-<commit>, and latest for the newest release:

docker run --rm --read-only -p 8081:8081 \
  -v "$PWD/secrets:/run/secrets/ariel:ro" \
  -e ARIEL_PROSPERO_URL=http://prosperod:7878 \
  -e ARIEL_GONZALO_URL=http://gonzalod:8080 \
  -e ARIEL_DISCORD_TOKEN_FILE=/run/secrets/ariel/discord-token \
  -e ARIEL_GONZALO_TOKEN_FILE=/run/secrets/ariel/gonzalo-token \
  -e ARIEL_PROSPERO_TOKEN_FILE=/run/secrets/ariel/prospero-token \
  -e ARIEL_DISCORD_GUILD_ID=123456789 \
  -e ARIEL_DISCORD_APPLICATION_ID=987654321 \
  ghcr.io/caliban-ai/ariel:0.3.0

The image runs arield as uid 10001 on a slim Debian base, sets ARIEL_HEALTH_ADDR=0.0.0.0:8081, and exposes port 8081. Ariel writes nothing locally, so it runs with a read-only root filesystem and no volume; credentials arrive as mounted files named by the ARIEL_*_TOKEN_FILE variables.

To build it yourself from the repository root:

docker build -t ariel:dev .

For Kubernetes, use the ariel chart in caliban-ai/helm-charts, which wires the Secrets, the probe and the Service URLs for you.

A running arield notifies nothing and refuses every command until a channel has a configuration record and your chat account is linked. Both are one command each (The ariel CLI):

# what this channel follows, how much it hears, and the highest role allowed in it
ariel channel set --provider discord --tenant 123456789 --channel 987654321 \
  --follows fleet --notify all --ceiling operator

# a one-time token to redeem in chat with /ariel link
ariel link new --role operator

Both read ARIEL_GONZALO_URL and ARIEL_GONZALO_TOKEN_FILE from the environment, or take --gonzalo-url and --token-file; --store <dir> points at a local gonzalo store instead. --tenant is the Discord guild ID and --channel the channel ID. From then on, further channels and invites can be handled from chat with /ariel configure and /ariel invite (Chat Commands) — the CLI is only needed to bootstrap the first one.

Try the Discord backend

Discord Setup covers creating the bot and the manual smoke test that exercises the backend against a real guild.

Configuration

arield is configured entirely through environment variables. It has no configuration file and no command-line options beyond --help and --version.

Reference

VariableDefaultMeaning
ARIEL_HEALTH_ADDR0.0.0.0:8081Socket address /healthz is served on. A value that is not a socket address (for example a bare port) stops arield at startup.
ARIEL_DISCORD_TOKEN_FILEunsetPath to a file holding the Discord bot token.
ARIEL_GONZALO_TOKEN_FILEunsetPath to a file holding arield's bearer token for gonzalod.
ARIEL_PROSPERO_TOKEN_FILEunsetPath to a file holding arield's API token for prosperod. Required once prosperod runs with API authentication on (prospero v0.8+); use a token with operate scope (ADR 0013). Without it, requests carry no token.
ARIEL_PROSPERO_URLunsetprosperod's base URL, for example http://prosperod:8080. https works too, verified against the platform's trust store.
ARIEL_GONZALO_URLunsetgonzalod's base URL, where the channel and access-control records live. http or https.
ARIEL_DASHBOARD_URLunsetLinked from every notification. A value that is not a URL stops arield at startup.
ARIEL_CHANNEL_RELOAD_SECS60How often arield re-reads the channel configuration records, so a channel added, retired or re-scoped takes effect without a restart. A value that is not a whole number of seconds above zero stops arield at startup.
RUST_LOGwarn,arield=info,ariel_daemon=info,ariel_core=info,ariel_discord=infoWhich log lines arield writes to stderr, as tracing filter directives. The default shows Ariel's own info lines and only warnings from its dependencies. A filter that does not parse stops arield at startup.
ARIEL_LOG_FORMATtexttext for one human-readable line per event, or json for one JSON object per line, for log shipping. Any other value stops arield at startup.
ARIEL_DISCORD_GUILD_IDunsetThe guild /ariel is registered in. Numeric; anything else stops arield at startup.
ARIEL_DISCORD_APPLICATION_IDunsetThe Discord application answering interactions. Numeric.

What arield does with less than all of it

arield always serves /healthz. The bridge itself needs prosperod, gonzalod and a chat provider:

  • Without ARIEL_PROSPERO_URL or ARIEL_GONZALO_URL it serves health only, and says so on startup.
  • Without a complete Discord configuration (token file, guild ID and application ID) there is no chat provider, so it serves health only.
  • With all of it, it reads every channel configuration record belonging to the running provider, watches the fleet, and notifies each channel that follows an event's workspace. A channel record naming another provider is skipped: it belongs to a different backend in the same fleet.
  • With no channel records at all it runs and notifies nothing, logging a warning.

After a restart it does not replay what it missed (ADR 0011): agents already finished are not announced, and each running agent gets a fresh live message.

Credentials are files

Per ADR 0008, a credential never arrives as a plain environment variable. Each is a file, normally a mounted Kubernetes Secret, named by an ARIEL_*_TOKEN_FILE variable.

  • An unset variable means that credential is absent.

  • A set variable whose file is missing, unreadable, or empty stops arield with a non-zero exit. The error names the variable and path, never the contents:

    arield: ARIEL_DISCORD_TOKEN_FILE: cannot read /run/secrets/ariel/discord-token: No such file or directory (os error 2)
    
  • One trailing newline (\n or \r\n) is removed, so a file written with echo works.

  • Tokens are held in a type that formats as [redacted], so they cannot reach logs.

Rotating a token means replacing the file and restarting arield.

gonzalo compatibility

Ariel keeps its people, bindings, role grants, channel configuration, link tokens and audit trail as gonzalo records (ADR 0003). Those record kinds are defined by gonzalo ADR 0022, and the channel configuration record by gonzalo ADR 0023.

  • Minimum gonzalo: 0.7.0, the first release carrying the access-control record kinds (gonzalo v0.7.0). 0.6.0 and earlier cannot decode them.
  • Upgrade gonzalo first. A gonzalod, or any gonzalo peer that syncs with it, older than that cannot decode these kinds. Upgrade every gonzalo binary that will hold or sync Ariel's records before arield writes one.

Health endpoint

GET /healthz answers 200 with body ok; every other path is 404. It reports only that the process is up and serving, and is suitable for liveness and readiness probes. The container image exposes port 8081.

Build-time selection

Which chat providers exist in a binary is decided by Cargo features, not configuration: ariel-daemon's discord feature is on by default. arield names them on its startup log line, providers=["discord"], or providers=[] for a --no-default-features build. Which compiled provider actually runs is configuration: today, whether ARIEL_DISCORD_TOKEN_FILE, ARIEL_DISCORD_GUILD_ID and ARIEL_DISCORD_APPLICATION_ID are all set. A binary built without discord has no provider to configure and serves health only.

The ariel CLI

ariel is the operator command. It manages channel configuration and mints link tokens. Channel configuration is the record that decides which workspaces a chat channel follows, how much it hears, and the highest role a command run there can have (ADR 0009).

Where the records live

Channel configuration is a gonzalo record (ADR 0003), so every command needs a store:

FlagMeaning
--gonzalo-url (or ARIEL_GONZALO_URL)gonzalod's base URL.
--token-file (or ARIEL_GONZALO_TOKEN_FILE)A file holding the bearer token for gonzalod.
--store <dir>A local gonzalo store directory instead of gonzalod.

--store and --gonzalo-url are mutually exclusive.

Show a channel

$ ariel channel show --provider discord --tenant 123456789 --channel 987654321
discord/123456789/987654321
  follows: caliban, prospero
  notify:  all
  ceiling: viewer

A channel with no configuration exits non-zero and says it is not configured. Ariel ignores such a channel: it receives no notifications, and commands there are refused.

Create or change one

ariel channel set creates the configuration, or changes only the fields you name.

$ ariel channel set --provider discord --tenant 123456789 --channel 987654321 \
    --follows caliban,prospero --notify failures --ceiling operator
created discord/123456789/987654321
  follows: caliban, prospero
  notify:  failures
  ceiling: operator
FlagValuesNotes
--followsfleet, or a comma-separated list of workspace namesRequired when creating a channel, since a channel that follows nothing would never hear anything. fleet follows every workspace, including ones created later.
--notifyall, terminal, failuresDefaults to all. A preset only narrows what a channel hears; it can never add event kinds.
--ceilingviewer, operator, adminDefaults to viewer, so a new channel allows read-only commands until you raise it on purpose.

Changing a field reports what moved:

$ ariel channel set --provider discord --tenant 123456789 --channel 987654321 --ceiling admin
updated discord/123456789/987654321
  follows: caliban, prospero
  notify:  failures
  ceiling: admin
  ceiling: operator -> admin

Setting exactly what is already stored reports unchanged and writes nothing.

Concurrent edits

Every change is a read, a patch and an optimistic write. If someone else changed the channel in between, nothing is written: the command exits with status 2, says so, and prints the configuration as it now stands, for you to re-apply your change on top of.

$ ariel channel set --provider discord --tenant 123456789 --channel 987654321 --ceiling admin
ariel: the channel changed since it was read; nothing was written
stored now: discord/123456789/987654321
  follows: caliban
  notify:  all
  ceiling: operator
$ echo $?
2

Audit

Creating or changing a channel appends an audit entry to gonzalo's fleet-audit namespace, naming the actor, the action (channel.create or channel.update), the record, and whether it succeeded. A command that changes nothing is not audited.

Before anyone can run a command, Ariel has to know which person a chat account belongs to. An administrator mints a one-time link token, and the person redeems it in chat.

$ ariel link new --role operator --workspace caliban
Link token (shown once; give it to the person privately):

  3f9c…e21a

It grants operator in workspace caliban, and expires in 24h.
Redeem it in chat with: /ariel link <token>
FlagMeaning
--roleviewer, operator or admin. Required.
--workspaceGrant the role in this workspace only. Without it the grant is fleet-wide.
--personLink to an existing person id rather than creating a new person.
--expires-hoursHow long the token stays redeemable. Defaults to 24.

The person then runs /ariel link <token> anywhere in chat. Ariel binds their account to a person, grants the role, and replies only to them, without repeating the token.

  • The token is shown once. Only a hash of it is stored; it appears in no record or audit entry.
  • It works once. Two people redeeming the same token at the same moment get one success and one "already used".
  • It expires. An expired token links nothing.
  • Tokens are marked used, never deleted, so a sync between gonzalo stores cannot bring a used token back.
  • An account that is already linked is refused without using up the token, so a mistaken attempt doesn't waste it.
  • Minting, and every redemption attempt, succeeded or not, is audited.

Linking writes identity records. Per ADR 0008, run it only against a gonzalod with authentication on; otherwise any pod could grant itself a role.

Chat Commands

Ariel answers one chat command, /ariel, with a subcommand per action. On Discord each subcommand is a typed slash command with its own options.

CommandNeedsWhat it does
/ariel link <token>nothingLinks your chat account to your fleet identity with a token an administrator gave you (The ariel CLI). Works in any channel, and the reply is private.
/ariel statusviewer, fleet-wideSummarizes the workspaces this channel follows: how many agents are active, each workspace's agents by status, and any workspace prosperod cannot reach. Links the dashboard.
/ariel spawn <workspace> <prompt>operator for that workspaceStarts an agent in workspace with prompt, and replies with its id. The channel's live notifications take it from there.
/ariel kill <agent>operator for the agent's workspaceStops an agent.
/ariel respawn <agent>operator for the agent's workspaceRestarts an agent from the prompt it was given. The restarted agent has a new id, which the reply names.
/ariel channelviewerShows what this channel follows, hears and allows.
/ariel configure [follows] [notify] [ceiling]adminChanges this channel's configuration, the same fields as ariel channel set. Audited, and a concurrent edit is reported rather than overwritten.
/ariel invite <role> [workspace] [hours]adminMints a one-time link token to hand to someone, who redeems it with /ariel link.

kill and respawn name an agent rather than a workspace, so Ariel looks the agent up in the fleet first and authorizes against the workspace it is in. An agent the fleet does not have is refused without asking prosperod to do anything, and nothing is audited, because nothing was attempted.

A channel with no configuration is refused before the fleet is read at all, so it cannot be used to find out which agents exist: the refusal reads the same whether or not the agent is real.

Who may run what

A command runs with the lower of two roles: the one you hold, and the channel's ceiling (ADR 0009, ADR 0012). So an admin in a channel with a viewer ceiling can run /ariel status but not /ariel spawn.

  • The channel must be configured with ariel channel set. Elsewhere only /ariel link works.
  • A command acts only on a workspace the channel follows. A channel following [caliban] cannot spawn into prospero, and its /ariel status shows only caliban.
  • A workspace grant counts only in that workspace. It lets you spawn there, but /ariel status covers the fleet the channel follows, so it needs a fleet-wide grant.
  • Your chat account must be linked. An unlinked account is told how to link.

Administering a channel from chat

/ariel configure and /ariel invite do from chat what ariel channel set and ariel link new do from a terminal, so adding a channel or onboarding someone no longer needs access to gonzalod (The ariel CLI). Both need admin, so a channel whose ceiling is operator cannot reconfigure itself — which is the point of the ceiling.

Two things worth knowing:

  • Bootstrapping is still a CLI job. Commands only work in a configured channel, so the first channel has to be created with ariel channel set.
  • An invite's token is private or it does not exist. The reply carries a live secret, so it is always private. On a chat platform that can neither reply ephemerally nor send a direct message, /ariel invite refuses and mints nothing, rather than minting a token Ariel cannot hand over safely. Nobody can invite at a role above the one they act with in that channel.

Replies

A result is posted publicly, so the channel sees the fleet and who started what. A refusal or failure is private to the person who asked, and says what would change the answer: which role is missing, which workspace the channel does not follow, or that prosperod does not know the workspace.

When prosperod itself refuses Ariel's own token, the person is told an administrator needs to check it, and arield logs an error naming ARIEL_PROSPERO_TOKEN_FILE.

Who started an agent

A command that changes the fleet tells prosperod which person it is for, so an agent can be traced back to whoever asked for it. Ariel authenticates with one operate token for the whole chat workspace, so prosperod's own actor reads ariel on every spawn; the person travels beside it, in prospero v0.9's X-Prospero-On-Behalf-Of header.

The channel sees it on the agent's live message, as a started by field naming the person. Three things are worth knowing about it:

  • It is an assertion, not a fact prosperod checked. Prosperod authenticated Ariel's token and cannot verify that the named person asked for anything, so it records the claim beside the credential that made it. Trust it as far as you trust Ariel's token — which is also why a false claim stays attributable. The audit line reads actor=ariel asserted_on_behalf_of=<person>.
  • The person is named, not their id. What travels is the gonzalo person id; Ariel resolves it to that person's display name before the channel sees it. An id with no person record behind it is shown as it came rather than dropped, so the attribution degrades to something useful rather than to nothing.
  • An agent started outside Ariel says nothing. A spawn from the prospero CLI, an automation, or another client carries no person, and its message looks exactly as it did before. The same is true against a prosperod older than v0.9, which ignores the header.

Gonzalo's audit trail records the person for every mutating command either way (see below); this is what makes the same attribution visible in prospero and in the channel.

Audit

Every /ariel spawn, /ariel kill and /ariel respawn leaves exactly one audit entry in gonzalo — action command.spawn, command.kill or command.respawn, target the workspace: Denied when refused, and Succeeded or Failed according to what prosperod did. A command with a missing argument, or one naming an agent the fleet does not have, is answered with the usage or a refusal and not audited, since nothing was attempted. /ariel status is read-only and not audited.

Discord Setup

Discord is Ariel's first chat platform. The backend, ariel-discord, is built on twilight (ADR 0010) and compiled into arield by the default discord feature.

arield starts the backend as soon as ARIEL_DISCORD_TOKEN_FILE, ARIEL_DISCORD_GUILD_ID and ARIEL_DISCORD_APPLICATION_ID are all set (Configuration): it registers the /ariel commands, reads interactions over the Gateway, and posts notifications. The smoke test below exercises the backend on its own against a real guild, without a prosperod or gonzalod.

What the backend does

  • Commands. Registers one guild slash command, /ariel, with a subcommand for each command the core declares — link, status, spawn, kill, respawn, channel, configure and invite (Chat Commands). Guild commands update immediately.
  • Receiving. Connects to the Gateway with no privileged intents, since interactions arrive without them, and turns /ariel interactions into commands for the core. Other interactions are ignored.
  • Replying. Answers within Discord's 3-second interaction deadline. If the core has not replied after 2 seconds, the backend defers on its behalf, then edits the deferred response or sends a follow-up. Private replies are ephemeral.
  • Posting. Sends messages as embeds (title, description, inline fields, link) colored by severity, edits them in place, opens direct messages, and starts threads from a message.
  • Limits. Advertises Discord's embed limits (256-character title, 4096-character description, 25 fields of 1024 characters, 6000 characters in total) and a send budget of a burst of 5 and 3600 per hour. twilight's REST client handles Discord's own rate-limit buckets and retries.
  • Not supported yet: buttons and reading thread replies.

Smoke test

The smoke test is kept in the repository at docs/discord-smoke-test.md and included here.

The Discord backend's contract tests run against a stub of Discord's REST API, and never open a real Gateway connection (ADR 0010). This manual test checks the parts they cannot: a real bot token, slash command registration, posting, the Gateway connection, and answering an interaction inside Discord's three-second deadline.

One-time setup

  1. In the Discord developer portal, create an application and add a bot to it.
  2. Copy:
    • the bot token (Bot → Reset Token);
    • the application ID (General Information).
  3. Create a test guild, or use one you administer, and enable Developer Mode in Discord's settings so you can copy IDs.
  4. Invite the bot with the bot and applications.commands scopes and these permissions: View Channels, Send Messages, Embed Links, Create Public Threads, Send Messages in Threads. The portal's OAuth2 URL generator builds the link.
  5. Copy the guild ID and the ID of a channel the bot can post in.

No privileged Gateway intents are needed: interactions arrive without them.

Run

export DISCORD_TOKEN='...'
export DISCORD_APPLICATION_ID='...'
export DISCORD_GUILD_ID='...'
export DISCORD_CHANNEL_ID='...'
cargo run -p ariel-discord --example smoke

Keep the token out of shell history and never commit it.

Expected results

StepPass if
RegistrationThe example prints registered /ariel status in guild …, and /ariel status appears in the guild's slash command list within a few seconds (guild commands update immediately).
Postingariel smoke: connected appears in the channel as an embed, and the example prints posted message ….
GatewayThe example prints listening; … with no Gateway errors logged.
InteractionRunning /ariel status shows ariel smoke: ok in the channel, and the example prints received /ariel status … then replied: Ok(…). Discord never shows "The application did not respond".

Stop the example with Ctrl-C.

Recording a run

Note the date, the ariel commit, and any step that failed, in the pull request or ticket that needed the smoke test.

Changelog

All notable changes to Ariel are recorded here, in the style of Keep a Changelog.

Ariel is pre-1.0: a release bumps the minor version for new features and the patch version for fixes alone. The crates are not published to crates.io; a release is the container image ghcr.io/caliban-ai/ariel, built for linux/amd64 and linux/arm64 on every v* tag.

Unreleased

0.4.0 - 2026-10-06

Ariel says whose agent it is, and stops missing the quick ones.

A channel could see that an agent started but not whose it was: Ariel holds one operate token for a whole Discord, so every spawn was attributed to ariel. Agents now carry the person who asked for them, from the chat command through prosperod to the live message. And the notifier no longer polls: it watches prospero's fleet-wide stream, so an agent that starts and finishes between two old five-second polls — previously invisible — is announced like any other.

This release needs prosperod v0.9 or later. The fleet stream is where every notification now comes from, and a v0.8 daemon does not serve it.

Added

  • An agent started from chat says who started it (#59). Ariel holds one operate token for a whole Discord, so prosperod's actor read ariel for every spawn and a channel could not tell one person's agents from another's. Ariel now names the person on the requests a command makes, using prospero v0.9's X-Prospero-On-Behalf-Of header (prospero#251), and the live message gains a started by field. The person id that comes back on the event is resolved to their display name; an id with no person behind it is shown as it came, and an agent started outside Ariel renders exactly as before.

    Prosperod does not verify the header — it is Ariel's assertion, recorded beside the token that is authenticated. Requires prosperod v0.9 or later; an older daemon ignores the header and the field simply does not appear.

Changed

  • arield watches one fleet-wide event stream instead of polling the fleet and holding a connection open per agent (#55). It now reads prospero v0.9's GET /api/fleet/stream, whose SSE id: is a fleet cursor, and reconnects with ?from=<cursor> so a dropped connection neither skips an event nor repeats one.

    This fixes a gap rather than only a cost: an agent that started and finished between two five-second polls was never seen, so the channel heard nothing about it at all. The first connection asks for ?from=now, which is ADR 0011's rule stated to prosperod rather than approximated by skipping agents that are already terminal.

    One behaviour changes with it. A restart used to replay each running agent's history, so every running agent got a fresh live message immediately; now one appears on that agent's next event. For a working agent that is milliseconds, but an agent sitting idle — waiting on a permission prompt, say — stays invisible until it does something. Requires prosperod v0.9 or later.

  • The end-to-end smoke test runs against prosperod v0.9.0, up from v0.8.1, and now asserts that an agent spawned from chat reaches the channel naming the person who ran the command — the whole loop through a real prosperod and a real gonzalod. router_with_auth gained an automations argument in v0.9 (prospero#220), which the test passes None.

  • The Rust toolchain is pinned to 1.99.0 in rust-toolchain.toml, and the builder image moves from rust:1.95-bookworm to rust:1.99-bookworm (#68). CI resolved its toolchain at run time, so a new stable release could break a build whose code had not changed — which is what happened to prospero when stable moved 1.97 → 1.99. ariel is more exposed than most, because crates/e2e builds prospero from a git tag and so compiles its source too. Upgrading is now a deliberate, reviewable commit.

Docs: the README and the guide were brought up to v0.3.0, which had drifted behind the commands and configuration that shipped in it (#67).

0.3.0 - 2026-09-20

Ariel runs the fleet from chat, and takes care of itself while running.

/ariel kill and /ariel respawn finish the command set, so a stray agent is dealt with where it was noticed. /ariel channel, /ariel configure and /ariel invite move channel administration and onboarding into chat, which previously meant a port-forward to gonzalod and a locally built CLI. A channel record written while arield runs is now picked up without a restart — the first thing the in-cluster install got wrong. And arield speaks TLS, so it can reach a prosperod through an ingress rather than only across a namespace.

Added

  • arield can reach an https prosperod (#60 — #65). Its HTTP client was built without TLS, so an https URL failed before opening a connection — reaching the fleet from outside the cluster needed a port-forward. TLS goes through rustls, verifying against the platform's trust store, which the runtime image already populates. In-cluster plain HTTP is unchanged.
  • /ariel channel shows a channel's configuration, /ariel configure changes it, and /ariel invite mints a one-time link token (#58 — #64) — so adding a channel or onboarding someone no longer needs access to gonzalod. Both changing commands need admin and are audited. An invite's reply is always private, and on a platform that cannot answer privately it mints nothing; nobody can invite above the role they act with. The CLI and the chat command now share one parser, so they write the same record.
  • /ariel kill <agent> stops an agent and /ariel respawn <agent> restarts it from the prompt it was given, naming the restarted agent's new id (#57 — #63). Both need an operator in the agent's own workspace: the agent is looked up in the fleet first, and authorization is against the workspace it is in. A channel with no configuration is refused before the fleet is read, so it cannot be used to discover which agents exist.

Fixed

  • arield now picks up channel configuration changes while it runs (#56 — #62). A channel record written after startup was invisible until the process restarted — adding a chat channel meant restarting the daemon. It re-reads the records every ARIEL_CHANNEL_RELOAD_SECS (60 by default), starting, stopping and restarting notifiers as records appear, vanish or change. A failed read keeps the channels already served.

0.2.0 - 2026-09-19

Ariel answers for the fleet, not just about it.

/ariel status and /ariel spawn are the first commands that act on the fleet, each authorized on the person's role and the channel's ceiling and audited in gonzalo. arield also has a voice at last: it writes its log to stderr, so a refused token or a throttled bot is visible instead of silently dropped. The whole path — link, spawn, notify, status — now runs in CI against prosperod's and gonzalod's own server code, and was confirmed by hand in a real Discord guild against the home cluster.

Added

  • /ariel status summarizes the workspaces a channel follows, and /ariel spawn <workspace> <prompt> starts an agent (#20 — #51). Both are authorized on the person's role and the channel's ceiling; every spawn is audited, and prosperod errors reach the person as plain sentences (Chat Commands).
  • A headless end-to-end smoke test in its own CI job (#21 — #52): the bridge against prosperod's and gonzalod's own server code, with token authentication on and prospero's fake caliban for agents, covering linking, a spawn from chat, its notification to the finish, and fleet status.

Fixed

  • arield now writes its log to stderr, so it shows up in kubectl logs. Before this, every warning and error was silently dropped, including prosperod refusing Ariel's token and the chat platform rate-limiting it (#49 — #50). RUST_LOG sets the level and ARIEL_LOG_FORMAT=json switches to JSON lines.
  • arield checks its prosperod token at startup whenever ARIEL_PROSPERO_URL is set, even if gonzalod or the chat provider is not configured yet (#49 — #50).

0.1.0 - 2026-09-17

The first release: Ariel notifies chat about the fleet, and knows who is allowed to ask it for anything.

arield watches prosperod's fleet, keeps one live message per agent in every chat channel configured to follow that agent's workspace, and answers /ariel link so a person's chat account can be tied to their fleet identity. The ariel CLI configures channels and mints link tokens. Everything Ariel remembers — people, bindings, role grants, channel configuration, link tokens and the audit trail — lives in gonzalo; Ariel stores nothing of its own.

Not in this release: the fleet commands themselves (/ariel status, /ariel spawn), which land next (#20).

Added

  • The chat provider boundary. ChatProvider, addressing types, a provider-neutral Message, capabilities with limits and a send budget, and an in-memory ConsoleProvider for tests, plus a shared contract suite every backend must pass (#11, #2 — #33, #28).
  • A Discord backend on twilight: /ariel commands, embeds coloured by severity, edits, private replies, and the three-second interaction deadline handled by auto-deferring (#14 — #36).
  • The prospero client: fleet polling, per-agent SSE fan-in with gap handling, and spawn, kill, respawn and input (#12 — #27).
  • Notifications: one live message per agent, edited in place; a burst of five or more agents shares a summary; sends are paced against the provider's own budget, and rate limits, lost access and deleted messages are handled (#13, #19 — #34, #40).
  • arield, the daemon: reads its configuration from the environment, connects to prosperod, gonzalod and Discord, notifies every channel that follows an event's workspace, serves /healthz, and shuts down cleanly. A restart replays nothing (#19, #30 — #41, #35).
  • Access-control records in gonzalo: typed reads and optimistic writes for people, identity bindings, role grants, channel configuration and link tokens, with an append-only audit trail (#15 — #38).
  • Account linking: ariel link new mints a one-time token and /ariel link <token> redeems it in chat. Only a hash of the token is stored, it works once, and every attempt is audited (#16 — #45).
  • Two-key command authorization: a command runs at the lower of the person's role and the channel's ceiling; an unlinked account gets nothing, and every mutating command is audited, allowed or denied (#17 — #44).
  • Channel configuration from the CLI: ariel channel show and ariel channel set, audited, with a concurrent edit reported as a conflict rather than overwritten (#18 — #42).
  • Authentication to prosperod: a bearer token from ARIEL_PROSPERO_TOKEN_FILE, sent on every request including the event stream, for prosperod v0.8's API authentication (#46 — #47).
  • A container image ghcr.io/caliban-ai/ariel for linux/amd64 and linux/arm64, a health endpoint, and file-based credentials (#30 — #35).
  • The ADR log (0001–0013) and an mdBook guide published to GitHub Pages (#9 — #24, #37).
  • CI: format, lint, build, test, a build with no chat backend, a guard that ariel-core pulls in no chat SDK, and an 85% line-coverage floor (#10 — #25).

Architecture Decision Records

ADR 0001 · Record architecture decisions

  • Status: accepted
  • Date: 2026-09-13

Context

Ariel began as a design spec (docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md in the caliban-ai umbrella workspace), and several of its load-bearing decisions were settled there before any code existed. That spec lives outside this repository and is still a draft with open questions, so the settled decisions are easy to lose among the unsettled ones and invisible to someone reading the code. The sibling repos caliban, prospero, and gonzalo each keep an ADR log.

Decision

We will keep an Architecture Decision Record log under docs/adr/, in MADR-lite format, matching the sibling repos. Each architecturally significant, hard-to-reverse, or future-constraining decision gets one append-only record with Context, Decision, and Consequences. Superseded decisions are marked and linked, never deleted.

ADRs 0002–0004 are retrospective: they record decisions the design spec already settled. Decisions still open in the spec (the provider trait's exact shape, channel mapping, rate limits, secrets and deployment) get their own ADRs as they are made.

Consequences

  • Positive: One durable home, beside the code, for "why"; the same format a reader already knows from the sibling repos; settled decisions are separated from the spec's open questions.
  • Negative: An ongoing discipline cost — a significant decision now means writing an ADR, not just code. Retrospective ADRs risk rationalizing after the fact rather than capturing the live trade-off.
  • Revisit if: the MADR-lite format diverges from the cross-sibling standard, or the log proves too heavyweight for the project's cadence.

ADR 0002 · Standalone service: through prospero, with gonzalo, to caliban only via prospero

  • Status: accepted
  • Date: 2026-09-13
  • Source: docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md (caliban-ai umbrella workspace) §Guiding decisions 1, §Architecture, §Non-goals

Context

The caliban-ai stack has no human surface where teams actually work: chat. We want fleet notifications, commands, approvals, and conversation to reach Discord, Slack, and Teams.

Chat integration drags in concerns none of the existing services have: platform SDKs with large TLS and websocket trees, OAuth, request signature verification, and hard platform rate limits. The options weighed were:

  • Bolt chat onto prospero. Prospero already serves fleet events, but every prosperod would then carry chat SDKs and their attack surface, and prospero's release cadence would be tied to three chat platforms' API churn.
  • Talk to caliband directly. This skips a hop but duplicates prospero's fleet discovery and supervision, and creates a second control path that can disagree with prospero about the fleet.
  • A standalone bridge. A fourth service that owns the chat boundary and reaches the rest of the stack only through their public interfaces.

The stack already keeps one concern per repository, and prospero keeps the same wire-format discipline toward caliban that this decision asks of Ariel.

Decision

We will build Ariel as a standalone service, a sibling to prospero and gonzalo, in its own repository and cargo workspace, shipping an arield daemon and an ariel operator CLI. Its couplings to the stack are fixed:

  • Through prospero. All fleet control and fleet events go over prospero's public HTTP and SSE API. Ariel takes no dependency on prospero crates; the wire format is the contract.
  • With gonzalo. State lives in gonzalo records (see ADR 0003).
  • To caliban only through prospero. Ariel never talks to caliband directly.

Out of scope: a web UI (prospero has a dashboard) and replacing the prospero CLI or dashboard. Ariel is a complementary surface.

Consequences

  • Positive: Chat SDKs, secrets, and rate limiting stay behind one boundary that can be deployed, scaled, and upgraded on its own. A prosperod never loads chat code. There is exactly one fleet control path, so chat and the dashboard cannot disagree about the fleet.
  • Negative: Another service to deploy and operate. Without prospero crates, Ariel must obtain prospero's wire types another way (mirrored types pinned by golden fixtures, or a pinned git dependency), decided in #12. Any capability prospero's API lacks — for example a fleet-wide event feed, or the PermissionRequested event approvals need — must be built upstream first or worked around.
  • Revisit if: prospero's public API cannot express what chat needs without Ariel reaching into prospero internals, or operating a separate service proves costlier than the isolation is worth.

ADR 0003 · Ariel stores nothing of its own; its state is gonzalo records

  • Status: accepted
  • Date: 2026-09-13
  • Source: docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md (caliban-ai umbrella workspace) §Guiding decisions 4, §Identity, RBAC & audit, §Non-goals

Context

Ariel authorizes commands per person, not per channel. That needs durable state: people, the chat and SSO accounts bound to them, role grants, per-channel configuration and role ceilings, one-time link tokens, and an audit trail of every mutating action.

The options weighed were:

  • A local database in arield (SQLite, or Postgres in a cluster). Simple to start, but it is a new persistence substrate to back up, migrate, and replicate, and the identity model would be trapped in one service on one host.
  • Gonzalo records. Gonzalo already provides versioned records with optimistic concurrency, so concurrent grant edits surface a conflict instead of silently losing one; append-only merge for audit; and syncable, shareable storage across its substrates.

People and roles are not Ariel-specific: prospero's own API authentication (caliban-ai/prospero#2) needs the same model, which argues for keeping it where other services can read it.

Decision

We will keep no persistent state inside Ariel. Identity, role grants, channel configuration, link tokens, and the audit trail are gonzalo records, read and written through gonzalod. arield holds only in-memory, reconstructible state, such as open event streams and caches.

The record kinds themselves are gonzalo's to define. Their names, merge classes, token hashing, and rollout order are being settled in gonzalo's ADR for fleet access-control records (caliban-ai/gonzalo#277), implemented in caliban-ai/gonzalo#278. Ariel consumes that model; it does not define a parallel one.

Consequences

  • Positive: No new persistence substrate to operate. Versioning, conflict surfacing, and audit merge come from gonzalo already tested. The identity model is shared by any service that governs human access to the fleet, and arield can be restarted or replaced without data loss.
  • Negative: Ariel's identity layer is blocked until gonzalo ships the new record kinds in a release, and every gonzalod must be upgraded before Ariel writes them. Every authorized command costs a round trip to gonzalod, and Ariel cannot authorize anything while gonzalod is unreachable.
  • Revisit if: gonzalo's record model cannot express a chat requirement, the gonzalod round trip makes commands miss platform response deadlines even with caching, or Ariel needs state that must not leave its own process.

ADR 0004 · One chat provider trait, feature-gated backend crates

  • Status: accepted
  • Date: 2026-09-13
  • Source: docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md (caliban-ai umbrella workspace) §Guiding decisions 2–3, §Crate layout, §Feature gating

Context

Ariel targets three chat platforms, built in order: Discord, then Slack, then Microsoft Teams. Routing, rendering, authorization, and the prospero and gonzalo clients are the same whichever platform a message arrives on; only the platform connection differs.

Chat SDKs are heavy. A Discord gateway library, the Slack API, and the Teams Bot Framework each pull in large TLS, websocket, and HTTP trees. Linking all three into every build means slower builds, larger binaries, and attack surface for platforms a deployment never uses.

The stack already has the pattern this calls for: gonzalo keeps each storage substrate and ticket system in its own crate behind an optional dependency and a facade feature, and picks which one runs at runtime.

Decision

We will put chat platforms behind one provider trait, ChatProvider, defined in ariel-core. ariel-core is always built and never depends on a chat platform SDK; router, renderer, auth, and clients live there and talk only to the trait.

Each platform is its own backend crate (ariel-discord, later ariel-slack and ariel-teams), consumed by ariel-daemon as an optional = true dependency behind a feature of the same name, with default = ["discord"].

  • Features are additive, never mutually exclusive. A build with discord,slack compiles and can run both.
  • The compiled feature set decides which providers can exist; configuration decides which one runs. Selection is never a feature.
  • A backend crate is created with its backend, not ahead of it.

This ADR fixes the boundary and the gating. The trait's exact shape, and whether one trait can honestly model Discord, Slack, and Teams, is decided separately (#2).

Consequences

  • Positive: A Discord-only build physically cannot load Slack or Teams code: smaller binaries, faster builds, and deploy-time capability control. Everything above the backend is testable against an in-memory provider with no platform. A new platform is a new crate, not a change to the core.
  • Negative: The trait must be designed across platforms whose features (buttons, modals, threads, interaction deadlines) differ, so it risks either a lowest common denominator or platform leaks. Every feature combination is a build to keep green, starting with --no-default-features.
  • Revisit if: #2 finds that one trait cannot model all three platforms without leaking platform types into ariel-core, or a backend needs logic that cannot stay out of the core.

ADR 0005 · Mirror prospero's wire types, pinned by golden fixtures

  • Status: accepted
  • Date: 2026-09-13

Context

Ariel reaches the fleet only through prospero's public HTTP and SSE API, with no dependency on prospero crates (ADR 0002). The client still needs typed values for what crosses that API: the fleet snapshot, agent statuses, fleet events, spawn requests and responses, and SSE gap frames.

prospero defines all of these in its prospero-types crate. That crate is not published: prospero ships a container image and has no crates.io pipeline. The options weighed were:

  • A pinned source dependency on prospero-types at a release tag. It is cheap, since the crate depends only on serde, but it contradicts ADR 0002 and couples Ariel's build to prospero's repository layout and release tags.
  • Mirror the types in ariel-core. Ariel defines only the subset it reads, and golden fixtures pin that subset to prospero's wire format.

prospero has no JSON wire fixtures of its own to copy. Its event enum also has no fallback for unknown variants, so a strict mirror would fail to decode a newer prosperod's events.

Decision

We will mirror the prospero wire types Ariel uses in ariel_core::prospero::types, and take no source dependency on prospero crates.

  • The mirror covers only the fields Ariel reads. Extra fields are ignored on decode.
  • Every enum Ariel matches on (AgentStatus, WorkspaceHealth, OutputStream, EventKind) has an Unknown fallback, so a newer prosperod that adds a variant degrades to "not understood" instead of failing to decode.
  • Golden fixtures in crates/core/tests/fixtures/prospero/ are written from prospero's type definitions and cite the prospero version they reflect (v0.7.0). Round-trip tests fail when the mirror stops matching them.

Consequences

  • Positive: Ariel builds with no access to prospero's source, and ADR 0002 holds. Unknown fallbacks let Ariel run against a newer prosperod. The fixtures make the contract Ariel relies on explicit and reviewable.
  • Negative: The mirror can drift from prospero silently. The fixtures only prove Ariel matches its own reading of the contract, not prospero's current output, so a prospero wire change is caught only if someone updates the fixtures. Each new field Ariel needs means changing the mirror and its fixtures by hand.
  • Revisit if: prospero publishes a wire-types crate, or prospero ships golden fixtures or a schema Ariel can test against directly, or drift causes a real decoding bug.

ADR 0006 · The ChatProvider trait: one core, capability-gated extras

  • Status: accepted
  • Date: 2026-09-13
  • Source: docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md (caliban-ai umbrella workspace) §Architecture, open question 8; issue #2

Context

ADR 0004 put every chat platform behind one ChatProvider trait in ariel-core and left the trait's shape to this decision. The risk is the one gonzalo's ADR 0010 names for ticket systems: a trait shaped against one platform either flattens the others or grows provider-specific escape hatches until it is no longer an abstraction.

We surveyed the official Discord, Slack and Microsoft Teams documentation (verified 2026-09-13) for the primitives Ariel's four capability layers use. They diverge exactly where Ariel needs them:

  • Response deadlines. Discord requires a response within 3 s, then allows follow-ups for 15 minutes. Slack requires an ack within 3 s, then allows 5 response_url uses within 30 minutes. Teams requires a response to an invoke within 5 s and has no follow-up token.
  • Commands. Only Discord has typed slash-command options. Slack and Teams deliver free text, and Slack slash commands cannot be used inside threads.
  • Private replies. Discord allows ephemeral messages only in reply to an interaction. Slack's are not guaranteed to be delivered. Teams' targeted messages expire after 24 hours.
  • Size. Discord is tightest: 4096 characters of embed description and 6000 across an embed. Slack recommends 4000 characters. Teams allows about 80 KB.
  • Threads. Discord threads are channels that auto-archive. Slack threads are replies to a parent message. Teams threads are reply chains. Reading replies needs elevated access on every platform.
  • Throttling. All three answer HTTP 429, each with different retry signalling.

The alternatives weighed for expressing what differs:

  • One trait with capability flags and Unsupported defaults, as gonzalo's TicketSource does.
  • A minimal trait plus optional extension traits (Threads, Editing) discovered through accessors. The type system then says what a backend supports, but there are more traits to wire and test, and a trait cannot say "supported, with a limit".
  • Every method required, with each backend degrading on its own. This is the simplest for the core, but fallback policy scatters across backends and the core can neither know nor test what actually happened.

Decision

We will define one ChatProvider trait with a small required core, capability-gated optional methods that default to Unsupported, and a Capabilities descriptor the core consults before calling them. The trait is #[async_trait] and Send + Sync: the daemon holds providers as trait objects, and native async fn in traits is not dyn-compatible on Rust 1.95.

Addressing

Every reference names its provider and tenant (a Discord guild, Slack workspace or Teams tenant). The MVP configures one tenant per provider, but serving several later needs no change to the trait, and #3's channel configuration gets a key that cannot collide across workspaces.

#![allow(unused)]
fn main() {
pub struct ChannelRef { pub provider: ProviderId, pub tenant: TenantId, pub channel: String }
pub struct UserRef    { pub provider: ProviderId, pub tenant: TenantId, pub user: String }
pub struct ThreadRef  { pub channel: ChannelRef, pub thread: String } // opaque per platform
pub enum Destination  { Channel(ChannelRef), Thread(ThreadRef) }
pub struct MessageRef { pub at: Destination, pub message: String }
}

UserRef.user must be stable for account linking: the Discord user ID, the Slack user ID within team_id (or the Enterprise Grid user ID), or the Teams Entra object ID within the tenant, never the per-bot 29: ID.

The trait

#![allow(unused)]
fn main() {
#[async_trait]
pub trait ChatProvider: Send + Sync {
    fn id(&self) -> ProviderId;
    fn capabilities(&self) -> Capabilities;
    async fn register_commands(&self, specs: &[CommandSpec]) -> Result<(), ProviderError>;
    async fn post(&self, to: &Destination, msg: &Message) -> Result<MessageRef, ProviderError>;
    fn inbound(&self) -> BoxStream<'static, Inbound>;

    // Capability-gated; each defaults to Err(ProviderError::Unsupported).
    async fn edit(&self, target: &MessageRef, msg: &Message) -> Result<(), ProviderError>;
    async fn direct_message(&self, user: &UserRef, msg: &Message) -> Result<MessageRef, ProviderError>;
    async fn start_thread(&self, root: &MessageRef, title: &str) -> Result<ThreadRef, ProviderError>;
}

#[non_exhaustive]
pub enum Inbound {
    Command(Command),         // layer 3
    Action(ActionClick),      // layer 2
    ThreadReply(ThreadReply), // layer 4
}
}

How the backend receives events (a gateway socket, Socket Mode, or an HTTPS endpoint) is its own concern; the trait assumes neither a socket nor a webhook.

Messages

The core hands backends one provider-neutral Message, which each maps to a Discord embed, Slack Block Kit or a Teams Adaptive Card:

#![allow(unused)]
fn main() {
pub struct Message {
    pub title: Option<String>,
    pub body: String,                 // bold, italic, code, links and lists only
    pub fields: Vec<(String, String)>,
    pub severity: Severity,           // Info | Success | Warning | Failure
    pub link: Option<Url>,
    pub actions: Vec<Action>,         // rendered only if capabilities().buttons
}
}

Commands

The core declares each command once as a CommandSpec (name, arguments, minimum role, summary). register_commands lets a backend with typed commands (Discord) generate typed subcommands from the specs. Backends without them (Slack, Teams) register a single /ariel command, and the core parses its text against the same specs. Either way the router receives the same Command:

#![allow(unused)]
fn main() {
pub struct Command {
    pub name: String,
    pub args: Args,
    pub user: UserRef,
    pub at: Destination,
    pub responder: Box<dyn Responder>,
}

#[async_trait]
pub trait Responder: Send + Sync {
    async fn defer(&self, visibility: Visibility) -> Result<(), ProviderError>; // calling it twice does nothing extra
    async fn reply(&self, msg: &Message, visibility: Visibility) -> Result<MessageRef, ProviderError>;
}
}
  • Deadlines belong to the backend. When a backend emits a Command, it starts a timer just short of its platform's deadline, and defers on the core's behalf if no reply or defer has happened. After a defer, reply uses the platform's follow-up path while it is valid (Discord's interaction token, Slack's response_url), then falls back to an ordinary send. Teams replies are always ordinary sends.
  • Private replies never become public. Visibility::Private uses an ephemeral reply if ephemeral_replies is set, otherwise a direct message if direct_messages is set, and otherwise fails.
  • The router guarantees a reply. It runs each command in its own task and answers a failed command with an error message, so backends need not detect a responder dropped without replying.

Capabilities, fallbacks and errors

#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct Capabilities {
    pub edit: bool,
    pub direct_messages: bool,
    pub ephemeral_replies: bool,
    pub typed_commands: bool,
    pub commands_in_threads: bool,
    pub buttons: bool,
    pub threads: bool,
    pub reads_thread_replies: bool,
    pub limits: Limits,
}

pub struct Limits {
    pub title_chars: usize,
    pub body_chars: usize,
    pub fields: usize,
    pub field_chars: usize,
    pub actions: usize,
    pub total_chars: usize,
    pub send_budget: SendBudget,
}

/// A per-channel token bucket the core paces sends against, before any 429.
pub struct SendBudget { pub burst: u32, pub per_hour: u32 }
}

send_budget lets the core pace sends ahead of the platform instead of only reacting to throttling: Discord advertises a burst of 5 and 3600 per hour, Slack a burst of 3 and 3600 per hour (about one message a second per channel), and Teams a burst of 7 and 1800 per hour (its per-conversation hourly cap).

The core applies fallbacks in one place before calling a provider, so a backend never receives a message it cannot send:

  • A body over the limit is truncated with a marker; the dashboard link carries the detail. Messages are never split across posts, which would spend rate-limit budget.
  • Fields over the limit keep the first N−1 plus a "+k more" field.
  • Actions are dropped when buttons is off; the link is kept.
  • A new message is posted when edit is off.
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum ProviderError {
    Unsupported(&'static str),
    RateLimited { retry_after: Option<Duration> },
    Forbidden,
    NotFound,
    InvalidMessage(String), // over a limit the core should have enforced: a bug
    Transport(Box<dyn std::error::Error + Send + Sync>),
}
}

A backend may absorb short, platform-managed waits (under about a second, such as an SDK's own bucket handling). Longer waits surface as RateLimited, and queueing, coalescing and dropping are the notification policy's concern (#4).

Capability matrix

LayerNeeded featureDiscordSlackTeams
1 NotificationsPost to a channelYesYesYes, once installed; not private channels
Edit in placeYeschat.updateupdateActivity
Tightest size6000 total, 4096 body~4000 text, 50 blocks~80 KB
Posting rate50 req/s global, per-channel buckets~1 msg/s per channel7/s, 60/30 s, 1800/h per conversation
2 ApprovalsButtons, update the clicked messageComponentsBlock Kit block_actionsAdaptive Card Action.Execute
3 ChatOpsTyped commandsYesFree textFree text
Deadline, then follow-up path3 s, 15-minute token3 s, 5 uses in 30 min5 s, ordinary sends
Private replyInteraction replies onlyEphemeral, not guaranteedTargeted, 24h expiry
Commands in threadsYesNoYes
Receiving eventsGateway socket (outbound)Socket Mode (outbound)Public HTTPS only
4 ConversationThreadsThread channels, auto-archiveReplies to a parentReply chains
Reading repliesPrivileged MESSAGE_CONTENT intentHistory scopes, channel membershipResource-specific consent

MVP boundary

  • In the MVP: everything above for layers 1 and 3 — id, capabilities, register_commands, post, inbound yielding commands, edit, direct_message, Responder, Capabilities with Limits, and ProviderError. Only the Discord backend is built.
  • Designed, deferred: Inbound::Action and message actions (#6), start_thread and Inbound::ThreadReply (#7), and the Slack and Teams backends. Inbound and Capabilities are #[non_exhaustive], so adding them does not break existing backends.
  • Out of scope: modals, reactions, file uploads, Discord autocomplete, and platform streaming APIs; editing in place covers progress updates.

Testing

  • ConsoleProvider (#11) is an in-memory provider in ariel-core. It records every outbound call, lets tests inject inbound events and inspect responder calls, and takes configurable capabilities, so each fallback is exercised.
  • A shared contract suite, behind a contract-tests feature in ariel-core and modelled on gonzalo's conformance suite, runs against any provider plus a harness that fakes the platform side. It checks that Unsupported matches the flags, that a posted message can be edited, that a message at the advertised limits posts, that typed and parsed commands produce the same Command, that auto-defer fires before the deadline, and that a private reply never reaches a public destination. It runs against ConsoleProvider and against each backend with a stub HTTP server and recorded payloads, never a live platform.
  • Table-driven core tests cover the fallback and truncation step, and golden tests cover free-text command parsing against the specs.

Consequences

  • Positive: The core, router and renderer are written once against one message model and one command model, and every fallback is decided and tested in one place. Backends stay thin: mapping, transport and deadlines. Deferred layers and new platforms are additive. The capability flags make platform gaps explicit and testable instead of hidden in backend code.
  • Negative: A neutral message model gives up some platform-native polish. Backends carry real work the trait cannot see: auto-defer timers, follow-up expiry, and mapping one Message to three formats. The matrix records platform behaviour as of 2026-09-13 and will drift. Teams needs public ingress and has no Rust SDK, so its backend means hand-written Bot Framework REST and token validation. Slack's missing in-thread commands will need a mention fallback when threads arrive.
  • Revisit if: a backend needs to branch on the provider in core code to work around a platform, a capability is needed that a flag cannot express, a deferred layer (#6, #7) cannot fit the variants reserved for it, or a platform changes its deadline or private-reply model enough to break the responder contract.

ADR 0007 · Notifications: one live message per agent, paced per channel

  • Status: accepted
  • Date: 2026-09-13
  • Source: docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md (caliban-ai umbrella workspace) open question 7; issue #4

Context

Prospero emits an Output event for every streamed chunk and a ToolStarted/ToolFinished pair for every tool call, so a busy agent produces far more events than a chat channel can absorb. Chat platforms throttle hard: Slack allows about one message a second per channel, Teams caps a bot at 7 sends a second and 1800 an hour per conversation, and Discord applies per-channel buckets under a 50 requests a second global limit. Posting one message per event would hit those limits within seconds and bury the channel.

ADR 0006 keeps backends thin: they absorb only short, platform-managed waits and surface longer ones as ProviderError::RateLimited. It also gives every backend edit (where supported) and a per-channel Limits.send_budget. Queueing, coalescing and dropping were left to this decision.

The alternatives weighed for how notifications appear:

  • One post per notable event, coalescing bursts into a digest post. The history is readable, but a busy fleet fills the channel.
  • A periodic digest, with immediate posts only for failures. The channel is quietest, but nothing except failures is real time.
  • One live message per agent, edited in place. A burst costs edits, not new posts.

And for where pacing lives: in each backend, which writes the burst logic three times against ADR 0006's thin-backend rule, or in the core.

Decision

We will notify with one live status message per agent, edited in place, paced and coalescing in the core.

Pipeline

Events flow from the FleetWatcher, through the channel's filters (#3), to a Notifier in ariel-core, one per configured channel, which calls the ChatProvider.

  • Notified kinds. AgentSpawned creates an agent's live message. StatusChanged updates it. AgentFinished (outcome, cost, turns) and AgentGone finalize it. Output, ToolStarted, ToolFinished, AgentInit, StorePersistFailed, RepoHealth and unknown kinds do not notify by default.
  • Latest state wins. For each agent the notifier keeps the posted MessageRef, the latest rendered state, and whether an edit is pending. Any number of changes before the next edit slot produce one edit.
  • Nothing terminal is lost. Intermediate states are superseded, never queued, so pending work is bounded by the number of live agents, not the number of events.
  • Restarts. Message refs are not persisted (ADR 0003). After a restart a running agent gets a fresh live message; the old one is left as it was.

Bursts

  • A new agent's first post waits 2 seconds, so the notifier can tell a lone agent from a burst. First notifications are therefore delayed by up to 2 s.
  • When 5 or more agents spawn in the same channel and workspace within that hold, they share one summary message, edited in place with counts of done, running and failed. Any spawn within 10 seconds of the summary's latest member joins it.
  • Agents that end failed or crashed still get their own post, so a failure is never hidden inside a count. Killed agents stay in the summary, since killing is deliberate.
  • The hold, threshold and window are configurable defaults.

Pacing and failures

  • One sender per channel, highest priority first: terminal updates (final edits and failure posts), then new live messages and summaries, then intermediate edits.
  • Pace ahead of the platform. The sender runs Limits.send_budget as a token bucket (Discord burst 5 and 3600 an hour, Slack burst 3 and 3600 an hour, Teams burst 7 and 1800 an hour), and spaces edits to the same message at least 2 seconds apart.
  • RateLimited { retry_after } pauses that channel's sender for retry_after, or backs off exponentially from 1 s to 60 s with jitter when none is given. Work keeps collapsing during the pause, and new spawns count toward a summary. After the pause the current state is rendered again; a stale edit is never resent.
  • Transport errors back off the same way.
  • NotFound means the live message was deleted: its ref is dropped and the next update posts one new message.
  • Forbidden means the bot lost access: the channel is marked unhealthy, the failure is logged, and nothing is sent to it until a re-check every 5 minutes.
  • After a long outage, terminal states are sent first, then each running agent's current state. Missed intermediate states are not replayed.
  • Without edit support, an agent gets one post at spawn (or a place in a summary) and one terminal post.

Behaviour under test

Each scenario runs against ConsoleProvider with paused tokio time, so the expected numbers are exact.

ScenarioInputExpected
A. One chatty agentSpawn at 0 s, 20 status changes within 3 s, finish at 10 s1 post; edits at least 2 s apart; the final message shows outcome, cost and turns
B. Burst30 spawns in one workspace within 2 s; 2 crash at 30 s; the rest finish by 120 s1 summary post at about 2 s; 2 failure posts at about 30 s; no individual posts for the other 28; the final summary reads 28 done and 2 failed; summary edits at least 2 s apart
C. Rate limitedThe next send returns RateLimited { retry_after: 5s }; 10 status changes and one finish arrive during the pauseAfter 5 s, the terminal update first, then exactly one edit with the latest state for each other agent
D. BudgetTeams budget (burst 7, 1800 an hour); 3000 status changes over an hourNever more than 7 sends in a burst or 1800 in the hour; every terminal state delivered
E. Message deletedAn edit returns NotFoundThe next update posts exactly one new message
F. Bot removedA send returns ForbiddenNo further sends until the 5-minute re-check

Consequences

  • Positive: A busy fleet costs a channel one message per agent, or one per burst, regardless of how many events prospero emits. Pacing, coalescing and failure handling are written and tested once, in the core, for every platform. Failures always stand out. Pending work is bounded by live agents, so an outage cannot grow an unbounded queue.
  • Negative: First notifications are delayed by up to 2 seconds. Channel history shows only each agent's latest state, not its path there. Live messages from before a restart go stale and are not cleaned up. The send budgets are hand-set from platform documentation and can drift from real limits, in which case the 429 backoff is the safety net.
  • Revisit if: channels need a record of intermediate states, the 2-second hold is noticeable to users, restarts leave enough stale messages to confuse people (persisting refs in gonzalo would then be worth it), or observed 429s show a budget is wrong.

ADR 0008 · Secrets, deployment, and the boundary around an unauthenticated prosperod

  • Status: accepted
  • Date: 2026-09-13
  • Revisited by: ADR 0013, after prosperod gained API authentication
  • Source: docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md (caliban-ai umbrella workspace) §IdP federation, open question 5; issue #5

Context

Ariel holds a Discord bot token now, and later Slack and Teams credentials. It is also a privileged client of two services that, in the home cluster, check nothing:

  • prosperod's HTTP API has no authentication (caliban-ai/prospero#2). Its Service is a ClusterIP on port 7878 with no NetworkPolicy, so any pod in any namespace can spawn and kill agents, including agent sandboxes, which run in prosperod's own caliban namespace. prosperod's service account can create, change and delete CalibanTask and Workspace resources, so reaching the port carries that authority. Its dashboard is published at prospero.hexadecimate.net through a Traefik route that admits only client addresses in 192.168.1.0/24 and has no authentication.
  • gonzalod runs with auth off. It supports namespace-scoped bearer-token principals (gonzalo ADR 0015), but its chart can only set one plaintext token from values, and the cluster sets none. Once Ariel keeps role grants in gonzalo (ADR 0003), any pod that can reach gonzalod could grant itself any Ariel role.

Ariel's two-key authorization only means something if Ariel is the gated path to prosperod's mutating routes, and if the grants it reads cannot be forged.

The cluster's existing conventions constrain the answer:

  • GitOps. A caliban-ai release reaches the cluster in three steps: the component image, then the published chart in caliban-ai/helm-charts, then a version pin in the private johnford2002/helm-charts caliban-system chart, which Argo CD syncs. Nobody runs helm upgrade by hand.
  • Secrets in git are SealedSecrets. prosperod's session-plane token is a SealedSecret mounted as a file.
  • k3s enforces NetworkPolicy. Private charts use an ingress policy that denies everything except Traefik; egress is not restricted anywhere yet.
  • Charts must pass kube-linter. Every container sets CPU and memory requests and limits. App namespaces enforce baseline pod security.

Alternatives weighed:

  • Secret source: External Secrets Operator with a vault backend (adds a platform component the cluster does not run), environment variables left to each deployer (leaves the home rollout undecided), or gonzalo records (rejected outright: gonzalo keeps revision history, and its git substrate commits every write).
  • Discord transport: Discord's HTTP interactions endpoint needs a public HTTPS route and still needs a Gateway connection later for thread replies (#7).
  • Fencing prosperod: admitting every pod in caliban re-admits agent sandboxes, and adding basic auth to the dashboard puts a login on the operator's own tool.
  • gonzalod identity: sending a token while leaving auth off lets identity ship on forgeable grants; a NetworkPolicy alone cannot exclude agents, which need gonzalod for memory.

Decision

Secrets

  • Every Ariel credential is a SealedSecret committed to the private caliban-system chart in namespace caliban: one Secret, ariel-credentials, with discord-token and gonzalo-token keys, and later platform keys.
  • Ariel's chart takes only references, a Secret name and key per credential. It mounts each as a file read through ARIEL_DISCORD_TOKEN_FILE and ARIEL_GONZALO_TOKEN_FILE. An unreadable file stops arield at startup. Rotation means resealing and restarting the pod.
  • Secrets are never stored as gonzalo records, never placed in chart values, and never logged.
  • Ariel needs no Kubernetes API access: the pod mounts no service-account token and has no RBAC.

Deployment

  • Image: ghcr.io/caliban-ai/ariel, built and released the way prospero's is: a Rust 1.95 build stage, a slim Debian runtime, non-root uid 10001, and multi-arch images tagged by version and sha-.
  • Charts: a generic ariel chart in caliban-ai/helm-charts, added to the umbrella but disabled by default, since without a bot token it never becomes Ready. The private caliban-system chart pins it, supplies the SealedSecret and values, and rolls it out when the pin merges.
  • Workload: a Deployment with one replica and the Recreate strategy. Two Gateway sessions on one bot token would answer every command twice, so a rollout accepts a few seconds of downtime instead. CPU and memory requests and limits are set. The container runs non-root with all capabilities dropped and a read-only root filesystem, which satisfies baseline pod security. arield serves /healthz for probes.
  • No ingress. Ariel receives Discord events over an outbound Gateway connection, so it has no Service exposed outside the cluster, no route and no public hostname. A Teams backend, which can only receive over public HTTPS, will add a route when it arrives.

Network boundary

  • prosperod: an ingress NetworkPolicy, optional in the public prospero chart (networkPolicy.enabled, a configurable list of allowed clients) and enabled in the private deploy, admits port 7878 only from Traefik in kube-system and from pods labelled app.kubernetes.io/name: ariel. Egress is unchanged.
  • Ariel: an ingress NetworkPolicy admits only the health port. Egress stays open, matching the rest of the cluster; Discord's endpoints are not a fixed set.
  • On first rollout, confirm that kubelet probes and kubectl port-forward still reach both pods, and admit the node addresses if they do not.

The bypass threat

Every path to prosperod's mutating routes that does not pass through Ariel's authorization, and its disposition:

PathDisposition
Any pod calling prosperod on 7878Closed by the allow-list
An agent sandbox in caliban calling prosperodClosed: agents are not on the allow-list
A person on 192.168.1.0/24 using the dashboardAccepted. LAN users are trusted operators with full fleet control and none of Ariel's authorization, until caliban-ai/prospero#2 adds API authentication
A tailnet client using the dashboard through the Tailscale subnet routerProbably closed: the traffic reaches Traefik from the connector pod's cluster address, which the LAN-only rule rejects. Verify on the live cluster with a request from a tailnet device, and restrict the route if it passes
A cluster admin using kubectl port-forwardAccepted: a cluster admin already has full control
A compromised TraefikOut of scope

Workload identity to gonzalod

  • Ariel authenticates to gonzalod as its own bearer-token principal (gonzalo ADR 0015), scoped to the access-control record namespaces settled in caliban-ai/gonzalo#277. This is machine identity, deliberately separate from the human identity records it stores.
  • gonzalod auth must be on before Ariel writes identity records. The gonzalo chart gains a principals file mounted from an existing Secret (caliban-ai/helm-charts#48), sealed in the private deploy with the ariel principal and an admin principal. Agents that use gonzalod get tokens in the same rollout, injected by the operator (caliban-ai/caliban-operator#41), so switching auth on does not cost them their memory. The container docs gain the principals file (caliban-ai/gonzalo#281).
  • Ariel's gonzalo client (#15) sends its token from the start. Account linking (#16) and two-key authorization (#17) are blocked until gonzalod auth is on.

Work this creates

TicketScope
caliban-ai/helm-charts#48gonzalo chart: principals file from an existing Secret
caliban-ai/helm-charts#49prospero chart: optional ingress NetworkPolicy with allowed clients
caliban-ai/ariel#30Image, release workflow, /healthz, file-based credentials
caliban-ai/helm-charts#50Generic ariel chart, disabled in the umbrella by default
caliban-ai/gonzalo#281Document GONZALO_AUTH_FILE
caliban-ai/caliban-operator#41Inject a gonzalod token into agent pods

The private johnford2002/helm-charts rollout, tracked there rather than on the caliban-ai board:

  1. Seal ariel-credentials and the gonzalod principals file.
  2. Enable prosperod's NetworkPolicy with Traefik and Ariel as clients.
  3. Enable gonzalod auth and give agent workspaces their token.
  4. Pin and enable the ariel chart.
  5. Confirm probes, kubectl port-forward and the Tailscale path on the live cluster.

Consequences

  • Positive: Ariel adds no new platform component and follows every existing cluster pattern. It exposes nothing publicly and needs no Kubernetes API access. Agents can no longer drive the fleet by calling prosperod directly. Role grants cannot be forged by other pods once gonzalod auth is on. Every remaining bypass is written down with an owner.
  • Negative: LAN devices keep full, unaudited fleet control through the dashboard until prospero#2 lands. Rollouts cost a few seconds of downtime. Rotating a token needs a reseal and a restart. Switching gonzalod auth on spans three repositories and blocks Ariel's identity layer until it is done. The Tailscale and probe behaviour are reasoned, not yet observed.
  • Revisit if: caliban-ai/prospero#2 adds API authentication (the dashboard exception and possibly the allow-list can then go), the cluster restricts egress, a second Ariel replica is needed (Gateway sharding or leader election would then be required), or the Tailscale check shows tailnet clients reach the dashboard.

ADR 0009 · Channel configuration: what a channel follows, hears, and allows

  • Status: accepted
  • Date: 2026-09-13
  • Source: docs/superpowers/specs/2026-07-03-ariel-chat-bridge-design.md (caliban-ai umbrella workspace) §Identity, RBAC & audit (ChannelConfig), open question 4; issue #3

Context

Each chat channel Ariel serves has a configuration record in gonzalo (ADR 0003). The design spec gave it a role ceiling, the workspaces it follows, and filters, but left open how many workspaces a channel can follow, what filters may change, the defaults for a new channel, and how filters relate to the ceiling. Prospero has since renamed repositories to workspaces, so the record uses workspaces throughout.

Two decisions already constrain it:

  • ADR 0006 keys every channel by provider, tenant and channel ID.
  • ADR 0007 fixes which event kinds notify and how they are paced: one live message per agent, burst summaries, and no Output or tool events.

The record's shape must be settled before gonzalo defines the access-control record kinds (caliban-ai/gonzalo#277).

Alternatives weighed:

  • Cardinality: exactly one workspace per channel (an ops view of the fleet would then need a channel per workspace), or every channel receiving the whole fleet narrowed by filters (a missing filter floods a team channel).
  • Filters: per-kind include and exclude lists, which would let a channel opt into Output and break ADR 0007's pacing; or no filters, which leaves a busy fleet channel unable to hear only about failures.
  • Defaults: following the whole fleet at operator, which makes a new channel noisy and privileged; or the whole fleet at failures-only, which needs two changes before a team channel is useful.

Decision

Each configured channel has one ChannelConfig record:

#![allow(unused)]
fn main() {
pub struct ChannelConfig {
    pub channel: ChannelRef,   // key: provider, tenant, channel (ADR 0006)
    pub follows: Follows,      // required; no default
    pub notify: NotifyPreset,  // default All
    pub ceiling: Role,         // default Viewer
}

pub enum Follows { Fleet, Workspaces(BTreeSet<String>) } // the set is non-empty
pub enum NotifyPreset { All, Terminal, Failures }
pub enum Role { Viewer, Operator, Admin }                // Viewer < Operator < Admin
}

What a channel follows

  • Fleet follows every workspace, including ones created later, so it never needs updating when a workspace is added.
  • Workspaces follows a non-empty set of workspace names, as prospero reports them. Following a name prospero does not list yet is allowed, since the workspace may be created later, but ariel channel show warns about it.

What a channel hears

An event notifies a channel only if its workspace is followed and the channel's preset admits it. Presets can only narrow ADR 0007's defaults, never add event kinds:

  • All: ADR 0007's live message per agent, with burst summaries.
  • Terminal: no live message; one post when an agent ends.
  • Failures: a post only when an agent ends failed or crashed, or is gone. ADR 0007's burst rule applies to these posts too: 5 or more within the window share one summary, so a wave of crashes cannot flood the channel.

What a channel allows

  • The ceiling governs commands only. It never hides notifications. A command's effective role is the lower of the person's role and the channel's ceiling.
  • Commands act only on followed workspaces. A channel following [caliban] cannot spawn or kill agents in another workspace; a Fleet channel can act on any workspace.
  • An unconfigured channel receives no notifications, and commands there are rejected with a hint, except /ariel link, which replies privately.
  • Changing a channel's configuration requires admin, and every change is audited.

Defaults

ariel channel add requires --follows, so no channel silently receives the whole fleet. notify defaults to All and ceiling to Viewer: anyone linked can use read-only commands, and spawning or killing needs the ceiling raised on purpose.

Worked examples

ChannelfollowsnotifyceilingEffect
#opsFleetFailuresViewerHears only about failures across the whole fleet, including workspaces added later. Only read-only commands such as /ariel status work, even for admins.
#caliban-teamWorkspaces {caliban}AllOperatorA live message per caliban agent. Linked operators and admins can spawn and kill agents, but only in caliban.

Fields for caliban-ai/gonzalo#277

The ChannelConfig record kind needs exactly these fields:

FieldTypeRequiredDefault
providerstring, e.g. discordyes—
tenantstring: guild, workspace or tenant IDyes—
channelstring: platform channel IDyes—
followstagged: { "kind": "fleet" } or { "kind": "workspaces", "names": [...] } with at least one nameyes—
notifyall, terminal or failuresnoall
ceilingviewer, operator or adminnoviewer

The record is keyed by provider, tenant and channel. It belongs to no person. Who changed it is recorded in the audit trail and in gonzalo's author stamp, not in the record.

How gonzalo implemented it

gonzalo adopted these fields in ADR 0023 (caliban-ai/gonzalo#297), keyed fleet/channels/<provider>:<tenant>:<channel>. Two details of the stored form differ from the table above, which described the logical fields rather than a serialization Ariel owns:

  • follows is stored inside gonzalo's atomic wrapper, as a one-element array: "follows":[{"kind":"workspaces","names":["caliban"]}]. That wrapper is what makes two concurrent follow edits conflict instead of merging field by field, which would otherwise pair one writer's kind with another's names.
  • ceiling serializes capitalized ("Viewer"), because FleetRole is shared with role grants and link tokens. Ariel reads it as a typed value, so the spelling does not reach Ariel's own behaviour.

gonzalo also enforces the non-empty rule on decode, so a hand-written record following an empty set of workspaces is rejected, not silently accepted.

Consequences

  • Positive: Both required examples are one record each. No channel receives the whole fleet by accident, and no new channel grants more than read-only commands. ADR 0007's pacing holds for every channel, because no configuration can add noisy event kinds. Scoping commands to followed workspaces keeps a team's channel from acting on another team's work.
  • Negative: A channel cannot ask for a specific mix of event kinds or transitions outside the three presets. A Fleet channel with a high ceiling can act on every workspace, so its ceiling needs care. Following a name that is misspelled produces only a warning, not an error.
  • Revisit if: users need a combination the presets cannot express, per-workspace presets within one channel, or thread channels (#7) need their own configuration.

ADR 0010 · Discord backend on twilight

  • Status: accepted
  • Date: 2026-09-13
  • Source: issue #14

Context

ADR 0004 puts each chat platform in its own feature-gated backend crate, and ADR 0006 keeps backends thin: they map the provider-neutral trait onto a platform, own its transport and response deadlines, and are tested against the shared provider contract with a stub platform rather than a live one. ADR 0008 has Ariel receive Discord events over the Gateway, an outbound WebSocket, so the backend needs a Gateway client as well as the REST API.

Two maintained Rust libraries cover Discord's Gateway and REST API. As of 2026-09-13:

  • serenity 0.12.5 (released 2025-12-20) is a batteries-included framework with its own event handler, cache and helpers. Its changelog calls 0.12.5 the last planned 0.12 release, and a next branch is in development, so a major version migration is likely soon. The framework owns the event loop.
  • twilight 0.17.1 (released 2025-12) is split into separate crates for the Gateway (twilight-gateway), REST API (twilight-http) and data model (twilight-model), with optional builders (twilight-util). It requires Rust 1.89. The application owns the event loop and composes the pieces it needs.

Decision

We will build ariel-discord on twilight: twilight-gateway for the Gateway connection, twilight-http for REST calls and interaction responses, and twilight-model for payloads, adding twilight-util only for its builders.

  • Only ariel-discord depends on twilight. No twilight type appears in ariel-core's public API; the backend converts at its boundary.
  • The backend owns its event loop: it reads Gateway events, turns interactions into Inbound events with a responder, and meets Discord's three-second interaction deadline by deferring on the core's behalf (ADR 0006).
  • TLS. twilight enables no rustls crypto provider, and building a client without one panics. ariel-discord pins rustls 0.23 with the ring provider and installs it before building any client, which also keeps aws-lc's C build out of the dependency tree.
  • Testing. Backend behaviour is verified by the shared provider contract suite. The REST client is pointed at a local stub of Discord's API over plain HTTP, with twilight's rate limiter turned off, and interactions are fed in through the same entry point the Gateway loop uses. The Gateway connection itself is covered by a documented manual smoke test in a test guild.

Consequences

  • Positive: The backend depends only on the Discord pieces it uses, which keeps its dependency tree and build small and matches ADR 0004's reasons for gating backends. Owning the event loop keeps the backend a thin, explicit mapping onto the ChatProvider trait, and separate HTTP and Gateway crates are easier to stub in tests than a framework's handlers. There is no pending major migration of the kind serenity's next branch signals.
  • Negative: More code in the backend: the Gateway event loop, reconnection handling at the application level, and interaction response bookkeeping that a framework would provide. twilight's lower-level API makes Discord's protocol details visible to whoever maintains the backend. twilight's REST client waits on Discord's rate-limit buckets and retries 429 responses itself, so the backend never returns ProviderError::RateLimited as ADR 0006 anticipates; ADR 0007's proactive send_budget pacing is the real guard against flooding a channel.
  • Revisit if: twilight's maintenance stalls, a twilight release makes the backend substantially harder to keep thin, or serenity's next major version removes the migration risk while offering something the backend needs.

ADR 0011 · A restarted daemon does not replay what it missed

  • Status: accepted
  • Date: 2026-09-15
  • Source: issue #19 ("decide restart behaviour"); ADR 0003, ADR 0007

Context

Prospero's per-agent stream replays an agent's whole history when a client connects, and from is the only cursor. A daemon that reconnects from 0 after a restart would re-post every event it had already delivered.

Ariel keeps no state of its own (ADR 0003), and gonzalo's access-control record kinds (gonzalo ADR 0022) have nothing to hold a stream position. The options were:

  • Persist a cursor. Add a record kind for the last delivered seq per agent, or per daemon. It would let Ariel resume exactly, at the cost of a new record kind in gonzalo, a write on every notification, and a cursor that is wrong whenever two daemons run at once.
  • Notify only what happens after startup. No new state anywhere, at the cost of a gap: an agent that ended while the daemon was down is never announced.

ADR 0007 already narrows the blast radius. Message refs are not persisted, so a restart cannot edit the live messages the previous process posted, and the FleetWatcher skips agents already terminal on its first poll.

Decision

A restarted arield starts from the fleet as it is, and replays nothing.

  • Agents already terminal at the first fleet poll are history: no message.
  • Each agent still running gets a fresh live message. The message the previous process posted is left as it was, showing the last state it saw.
  • Agents that started and ended entirely during the downtime are never notified. This is the accepted gap.
  • No cursor is stored, in gonzalo or anywhere else.

Consequences

  • Positive: No new record kind, no write per notification, and no cursor to be wrong when two daemons overlap during a rolling restart. Restart behaviour is a consequence of ADR 0003 rather than a mechanism of its own, and a restart costs at most one fresh message per running agent.
  • Negative: A downtime that spans an agent's whole life hides it from chat entirely; the fleet dashboard and prospero's own history remain the record. A running agent's old live message is never finalized, so a channel can hold a stale message that stops updating. An operator watching one channel cannot tell a restart gap from a quiet fleet.
  • Revisit if: restarts become frequent enough that missed terminal states matter (a rolling deployment on every chart change, say), or gonzalo gains a cursor-shaped record kind for another consumer, in which case resuming becomes cheap enough to reconsider.

ADR 0012 · Command authorization: grant scope, what is mutating, and when it is audited

  • Status: accepted
  • Date: 2026-09-16
  • Source: issue #17; ADR 0009, ADR 0008, gonzalo ADR 0022

Context

ADR 0009 fixes the two keys: a command runs at the lower of the person's role and the channel's ceiling, acts only on workspaces its channel follows, and is refused in an unconfigured channel. Issue #17 adds that a workspace-scoped grant applies only to that workspace, and that every mutating command is audited, allowed or denied.

Three questions were left open, and each decides what a user can do or what the trail records:

  • Which grants apply to a command that acts on no workspace, such as a fleet-wide status? A person may hold a fleet grant, a workspace grant, both, or neither.
  • What makes a command mutating. CommandSpec has a min_role but no flag saying whether the command changes anything.
  • When the audit entry is written. Before the command runs, only the decision is known; after it runs, the outcome is known too, but a caller might forget to write the entry.

Decision

Grant scope

  • A person's role for a command is the highest applicable grant.
  • A fleet grant always applies.
  • A workspace grant applies only to a command acting on that workspace. It never authorizes another workspace, and never a command that acts on no workspace.
  • A linked person with no applicable grant is denied (NoRole), distinct from an unlinked account (Unlinked).

Order of checks

Channel configured → workspace followed → account linked → applicable grant → effective role reaches min_role. The channel checks come first so that a refusal in a misconfigured channel says so, rather than blaming the person.

Mutating

A command is mutating when its min_role is above viewer. Read-only commands are open to viewers by construction; anything that needs operator or admin changes the fleet or its configuration.

Audit

Every mutating command leaves exactly one audit entry:

  • A denial is recorded by authorize itself, so no caller can skip it, with result Denied. An unlinked account is recorded as FleetActor::Unlinked { authenticator, subject }, so attempts by unknown accounts stay visible.
  • An allowed command is recorded by record_outcome after it runs, with Succeeded or Failed, so the trail shows what actually happened.
  • The entry carries the action (command.<name>), the target workspace (or fleet), and a reference to the chat message.

Read-only commands are not audited.

Consequences

  • Positive: Least privilege by default: a workspace grant cannot leak into fleet-wide or neighbouring commands, and a new person or channel can do nothing until someone grants it on purpose. Denials cannot go unaudited, and allowed entries record outcomes rather than intentions.
  • Negative: Someone holding only workspace grants cannot run fleet-wide read-only commands, even status, without a fleet viewer grant. "Mutating" is inferred from min_role, so a future command that changes state but is open to viewers would escape the audit. An allowed command whose caller never calls record_outcome leaves no entry; the router (#20) owns that call.
  • Revisit if: a read-only command must be audited, or a mutating one opened to viewers (add an explicit flag to CommandSpec); or people with only workspace grants need a workspace-filtered fleet view.

ADR 0013 · Ariel authenticates to prosperod with a scoped API token

  • Status: accepted
  • Date: 2026-09-17
  • Source: issue #46; revisits ADR 0008; prospero ADR 0010 (inbound API authentication, shipped in prospero v0.8.0)

Context

ADR 0008 was written while prosperod's API had no authentication. It planned no prospero credential for Ariel, contained the gap with a NetworkPolicy allow-list, and named prospero adding API authentication as a reason to revisit it.

That has happened. prospero v0.8.0 added scoped, declarative API tokens (prospero ADR 0010), and the home cluster's prosperod now runs with them on. Once a tokens file is configured, every request needs a token, loopback included, so an arield that sends none sees 401 on every fleet poll and stream and notifies nothing.

prosperod's scopes are ordered read < operate < admin:

ScopeAllows
readEvery GET, including the agent event stream
operateread, plus spawn, kill, respawn, input, end-input and removing an agent
adminoperate, plus adding, removing and configuring workspaces

A credential is sent as Authorization: Bearer <token>, including on the event stream. There is no query-parameter form.

Decision

  • arield authenticates to prosperod with its own API token, named ariel, sent as a bearer header on every request. It arrives as a file named by ARIEL_PROSPERO_TOKEN_FILE, like every other credential (ADR 0008): never a plain environment variable, never in chart values, and redacted when formatted.
  • The token has operate scope, the least that covers Ariel's commands: spawn and kill need it (#20), and notifications need only read. Ariel never needs admin, since it does not change workspaces.
  • Without a token, requests carry none, so arield still works against a prosperod with authentication off or older than v0.8.
  • A refused token is a distinct error. A 401 or 403 becomes ClientError::Auth, logged as an error naming ARIEL_PROSPERO_TOKEN_FILE, because retrying cannot fix it. At startup arield calls GET /api/session and logs the token name and scope prosperod sees.
  • prospero's attribution is mirrored. Events now carry an optional actor, the token that caused them (ADR 0005). Ariel's spawns will appear as ariel in prosperod's audit log and events.

Consequences

  • Positive: Ariel reaches prosperod as a named, least-privilege principal rather than as anonymous network access, and prosperod records which of its mutations were Ariel's. A missing or under-scoped token is visible at startup instead of as silent missing notifications.
  • Negative: one more credential to issue, seal and rotate. prosperod checks the token's scope, not the person behind a chat command; who may spawn is still decided by Ariel's own two-key authorization (ADR 0012), so an operate token in Ariel's hands is as powerful as the most privileged person Ariel will act for.
  • ADR 0008 now partly stands on different ground. Its NetworkPolicy allow-list is defence in depth rather than the only barrier, and the LAN dashboard exception it accepted is narrowed by prosperod's own login. The allow-list is kept; removing it is a separate decision.
  • Revisit if: prosperod gains per-request delegation (Ariel acting as the person rather than as itself), or Ariel needs admin for a workspace command.