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:
arieldwatches 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
/arielcommands —link,status,spawn,kill,respawn,channel,configureandinvite— 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
arielCLI configures channels and mints link tokens (ThearielCLI).
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: how Ariel sits beside prospero and gonzalo, and how the crates divide the work.
- Status & Roadmap: what is built, next, deferred and blocked.
- Getting Started: build, test, and run
arieldfrom source or from the published image. - Configuration: every setting
arieldreads. - The
arielCLI: channel configuration and link tokens. - Chat Commands: the
/arielcommands and who may run them. - Discord Setup: create a bot, register the commands, and run the manual smoke test.
- Changelog: what changed in each release.
- Architecture Decisions: the reasoning behind the design.
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'son_behalf_ofon the event envelope pinned by its own wire tests (ADR 0005). Every enum Ariel matches on has anUnknownfallback, 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 fromARIEL_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:
| Method | Route | Client call |
|---|---|---|
GET | /api/session | session() — who prosperod thinks Ariel is, and with which scopes |
GET | /api/fleet | fleet() |
POST | /api/workspaces/{workspace}/agents | spawn() |
POST | /api/agents/{id}/kill | kill() |
POST | /api/agents/{id}/respawn | respawn() |
POST | /api/agents/{id}/input | input() |
POST | /api/agents/{id}/end-input | end_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
| Crate | Binary | Role |
|---|---|---|
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-daemon | arield | The 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-cli | ariel | The 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:
| Crate | Feature | Default | Notes |
|---|---|---|---|
ariel-daemon | discord | yes | Compiles in ariel-discord. |
ariel-core | contract-tests | no | Exposes 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.
| Layer | Direction | What it does | State |
|---|---|---|---|
| Notifications | out | Agent started, changed status, finished | Built. 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. |
| ChatOps | both | Slash commands to list, spawn, kill, and restart agents | Built. Eight commands: link, status, spawn, kill, respawn, channel, configure, invite (Chat Commands). |
| Approvals | both, narrow | Approve or deny a risky action with buttons | Designed, deferred (#6). Waiting on a permission-request event from caliban and prospero. |
| Conversational | both, full | A chat thread is an agent session | Designed, 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
ChatProvidertrait, addressing types (ChannelRef,UserRef,ThreadRef,MessageRef), the provider-neutralMessage,CommandSpecandRole,CapabilitieswithLimitsandSendBudget, andProviderError(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-testsfeature, run against bothConsoleProviderand the Discord backend. ProsperoClientfor prosperod's fleet, spawn, kill, respawn, input, end-input and stream routes; an incremental SSE decoder with gap handling; andFleetWatcher, 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_ofon 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). Bothhttpandhttpswork, 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'sX-Prospero-On-Behalf-Ofheader. A value prosperod would answer400to — 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 astarted byfield 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 localFsStore. A lost write race is an ordinaryWrite::Conflictcarrying the winning record (gonzalo compatibility).
ariel-discord (ADR 0010)
- Registers one guild
/arielcommand with a subcommand perCommandSpec(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
/arielinteractions intoInbound::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
/arielcommand:link,status,spawn,kill,respawn,channel,configureandinvite. 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,killandrespawntell 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 logsshows a refused token or a throttled bot:RUST_LOGsets the level andARIEL_LOG_FORMAT=jsonswitches 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_URLis set, in the background so a slow prosperod never holds up the health endpoint. - Serves
GET /healthzand shuts down cleanly on Ctrl-C or SIGTERM.
ariel (CLI)
ariel channel showandariel channel setmanage a channel's configuration record: what it follows, how much it hears and its command ceiling (ThearielCLI). Changes are audited, and a concurrent edit is reported as a conflict instead of overwriting what is stored.ariel link newmints 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 (ThearielCLI).- 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-featuresbuild ofariel-daemon, a check thatariel-corepulls 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 thestarted byfield 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. Dockerfileand a release workflow that buildsghcr.io/caliban-ai/arielforlinux/amd64andlinux/arm64on native runners (no QEMU), validating on pull requests and pushing a multi-arch manifest onv*tags, tagged by version, bysha-<commit>, andlatest.- Deployment: the
arielchart 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
.profrawfiles (#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-discorddoes 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
operatescope 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 byfield 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-covand the LLVM tools (seescripts/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.
Configure a channel and link yourself
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
| Variable | Default | Meaning |
|---|---|---|
ARIEL_HEALTH_ADDR | 0.0.0.0:8081 | Socket 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_FILE | unset | Path to a file holding the Discord bot token. |
ARIEL_GONZALO_TOKEN_FILE | unset | Path to a file holding arield's bearer token for gonzalod. |
ARIEL_PROSPERO_TOKEN_FILE | unset | Path 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_URL | unset | prosperod's base URL, for example http://prosperod:8080. https works too, verified against the platform's trust store. |
ARIEL_GONZALO_URL | unset | gonzalod's base URL, where the channel and access-control records live. http or https. |
ARIEL_DASHBOARD_URL | unset | Linked from every notification. A value that is not a URL stops arield at startup. |
ARIEL_CHANNEL_RELOAD_SECS | 60 | How 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_LOG | warn,arield=info,ariel_daemon=info,ariel_core=info,ariel_discord=info | Which 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_FORMAT | text | text 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_ID | unset | The guild /ariel is registered in. Numeric; anything else stops arield at startup. |
ARIEL_DISCORD_APPLICATION_ID | unset | The 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_URLorARIEL_GONZALO_URLit 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
arieldwith 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 (
\nor\r\n) is removed, so a file written withechoworks. -
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
arieldwrites 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:
| Flag | Meaning |
|---|---|
--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
| Flag | Values | Notes |
|---|---|---|
--follows | fleet, or a comma-separated list of workspace names | Required when creating a channel, since a channel that follows nothing would never hear anything. fleet follows every workspace, including ones created later. |
--notify | all, terminal, failures | Defaults to all. A preset only narrows what a channel hears; it can never add event kinds. |
--ceiling | viewer, operator, admin | Defaults 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.
Link a chat account
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>
| Flag | Meaning |
|---|---|
--role | viewer, operator or admin. Required. |
--workspace | Grant the role in this workspace only. Without it the grant is fleet-wide. |
--person | Link to an existing person id rather than creating a new person. |
--expires-hours | How 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.
| Command | Needs | What it does |
|---|---|---|
/ariel link <token> | nothing | Links 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 status | viewer, fleet-wide | Summarizes 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 workspace | Starts 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 workspace | Stops an agent. |
/ariel respawn <agent> | operator for the agent's workspace | Restarts an agent from the prompt it was given. The restarted agent has a new id, which the reply names. |
/ariel channel | viewer | Shows what this channel follows, hears and allows. |
/ariel configure [follows] [notify] [ceiling] | admin | Changes 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] | admin | Mints 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 linkworks. - A command acts only on a workspace the channel follows. A channel following
[caliban]cannot spawn intoprospero, and its/ariel statusshows onlycaliban. - A workspace grant counts only in that workspace. It lets you spawn there,
but
/ariel statuscovers 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 inviterefuses 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,configureandinvite(Chat Commands). Guild commands update immediately. - Receiving. Connects to the Gateway with no privileged intents, since
interactions arrive without them, and turns
/arielinteractions 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
- In the Discord developer portal, create an application and add a bot to it.
- Copy:
- the bot token (Bot → Reset Token);
- the application ID (General Information).
- Create a test guild, or use one you administer, and enable Developer Mode in Discord's settings so you can copy IDs.
- Invite the bot with the
botandapplications.commandsscopes 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. - 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
| Step | Pass if |
|---|---|
| Registration | The 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). |
| Posting | ariel smoke: connected appears in the channel as an embed, and the example prints posted message …. |
| Gateway | The example prints listening; … with no Gateway errors logged. |
| Interaction | Running /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
operatetoken for a whole Discord, so prosperod'sactorreadarielfor 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'sX-Prospero-On-Behalf-Ofheader (prospero#251), and the live message gains astarted byfield. 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
-
arieldwatches one fleet-wide event stream instead of polling the fleet and holding a connection open per agent (#55). It now reads prospero v0.9'sGET /api/fleet/stream, whose SSEid: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_authgained an automations argument in v0.9 (prospero#220), which the test passesNone. -
The Rust toolchain is pinned to 1.99.0 in
rust-toolchain.toml, and the builder image moves fromrust:1.95-bookwormtorust: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, becausecrates/e2ebuilds 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
arieldcan reach anhttpsprosperod (#60 — #65). Its HTTP client was built without TLS, so anhttpsURL 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 channelshows a channel's configuration,/ariel configurechanges it, and/ariel invitemints a one-time link token (#58 — #64) — so adding a channel or onboarding someone no longer needs access to gonzalod. Both changing commands needadminand 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
arieldnow 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 everyARIEL_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 statussummarizes 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
arieldnow writes its log to stderr, so it shows up inkubectl 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_LOGsets the level andARIEL_LOG_FORMAT=jsonswitches to JSON lines.arieldchecks its prosperod token at startup wheneverARIEL_PROSPERO_URLis 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-neutralMessage, capabilities with limits and a send budget, and an in-memoryConsoleProviderfor tests, plus a shared contract suite every backend must pass (#11, #2 — #33, #28). - A Discord backend on twilight:
/arielcommands, 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 newmints 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 showandariel 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/arielforlinux/amd64andlinux/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-corepulls in no chat SDK, and an 85% line-coverage floor (#10 — #25).
Architecture Decision Records
- ADR 0001 · Record architecture decisions
- ADR 0002 · Standalone service: through prospero, with gonzalo, to caliban only via prospero
- ADR 0003 · Ariel stores nothing of its own; its state is gonzalo records
- ADR 0004 · One chat provider trait, feature-gated backend crates
- ADR 0005 · Mirror prospero's wire types, pinned by golden fixtures
- ADR 0006 · The
ChatProvidertrait: one core, capability-gated extras - ADR 0007 · Notifications: one live message per agent, paced per channel
- ADR 0008 · Secrets, deployment, and the boundary around an unauthenticated prosperod
- ADR 0009 · Channel configuration: what a channel follows, hears, and allows
- ADR 0010 · Discord backend on twilight
- ADR 0011 · A restarted daemon does not replay what it missed
- ADR 0012 · Command authorization: grant scope, what is mutating, and when it is audited
- ADR 0013 · Ariel authenticates to prosperod with a scoped API token
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
PermissionRequestedevent 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
arieldcan 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,slackcompiles 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-typesat 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 anUnknownfallback, 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_urluses 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
Unsupporteddefaults, as gonzalo'sTicketSourcedoes. - 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,replyuses the platform's follow-up path while it is valid (Discord's interaction token, Slack'sresponse_url), then falls back to an ordinary send. Teams replies are always ordinary sends. - Private replies never become public.
Visibility::Privateuses an ephemeral reply ifephemeral_repliesis set, otherwise a direct message ifdirect_messagesis 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
buttonsis off; the link is kept. - A new message is posted when
editis 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
| Layer | Needed feature | Discord | Slack | Teams |
|---|---|---|---|---|
| 1 Notifications | Post to a channel | Yes | Yes | Yes, once installed; not private channels |
| Edit in place | Yes | chat.update | updateActivity | |
| Tightest size | 6000 total, 4096 body | ~4000 text, 50 blocks | ~80 KB | |
| Posting rate | 50 req/s global, per-channel buckets | ~1 msg/s per channel | 7/s, 60/30 s, 1800/h per conversation | |
| 2 Approvals | Buttons, update the clicked message | Components | Block Kit block_actions | Adaptive Card Action.Execute |
| 3 ChatOps | Typed commands | Yes | Free text | Free text |
| Deadline, then follow-up path | 3 s, 15-minute token | 3 s, 5 uses in 30 min | 5 s, ordinary sends | |
| Private reply | Interaction replies only | Ephemeral, not guaranteed | Targeted, 24h expiry | |
| Commands in threads | Yes | No | Yes | |
| Receiving events | Gateway socket (outbound) | Socket Mode (outbound) | Public HTTPS only | |
| 4 Conversation | Threads | Thread channels, auto-archive | Replies to a parent | Reply chains |
| Reading replies | Privileged MESSAGE_CONTENT intent | History scopes, channel membership | Resource-specific consent |
MVP boundary
- In the MVP: everything above for layers 1 and 3 —
id,capabilities,register_commands,post,inboundyielding commands,edit,direct_message,Responder,CapabilitieswithLimits, andProviderError. Only the Discord backend is built. - Designed, deferred:
Inbound::Actionand message actions (#6),start_threadandInbound::ThreadReply(#7), and the Slack and Teams backends.InboundandCapabilitiesare#[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 inariel-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-testsfeature inariel-coreand modelled on gonzalo's conformance suite, runs against any provider plus a harness that fakes the platform side. It checks thatUnsupportedmatches the flags, that a posted message can be edited, that a message at the advertised limits posts, that typed and parsed commands produce the sameCommand, that auto-defer fires before the deadline, and that a private reply never reaches a public destination. It runs againstConsoleProviderand 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
Messageto 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.
AgentSpawnedcreates an agent's live message.StatusChangedupdates it.AgentFinished(outcome, cost, turns) andAgentGonefinalize it.Output,ToolStarted,ToolFinished,AgentInit,StorePersistFailed,RepoHealthand 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_budgetas 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 forretry_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.
NotFoundmeans the live message was deleted: its ref is dropped and the next update posts one new message.Forbiddenmeans 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.
| Scenario | Input | Expected |
|---|---|---|
| A. One chatty agent | Spawn at 0 s, 20 status changes within 3 s, finish at 10 s | 1 post; edits at least 2 s apart; the final message shows outcome, cost and turns |
| B. Burst | 30 spawns in one workspace within 2 s; 2 crash at 30 s; the rest finish by 120 s | 1 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 limited | The next send returns RateLimited { retry_after: 5s }; 10 status changes and one finish arrive during the pause | After 5 s, the terminal update first, then exactly one edit with the latest state for each other agent |
| D. Budget | Teams budget (burst 7, 1800 an hour); 3000 status changes over an hour | Never more than 7 sends in a burst or 1800 in the hour; every terminal state delivered |
| E. Message deleted | An edit returns NotFound | The next update posts exactly one new message |
| F. Bot removed | A send returns Forbidden | No 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
calibannamespace. prosperod's service account can create, change and deleteCalibanTaskandWorkspaceresources, so reaching the port carries that authority. Its dashboard is published atprospero.hexadecimate.netthrough a Traefik route that admits only client addresses in192.168.1.0/24and 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 privatejohnford2002/helm-chartscaliban-systemchart, which Argo CD syncs. Nobody runshelm upgradeby 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
calibanre-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-systemchart in namespacecaliban: one Secret,ariel-credentials, withdiscord-tokenandgonzalo-tokenkeys, 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_FILEandARIEL_GONZALO_TOKEN_FILE. An unreadable file stopsarieldat 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 andsha-. - Charts: a generic
arielchart incaliban-ai/helm-charts, added to the umbrella but disabled by default, since without a bot token it never becomes Ready. The privatecaliban-systemchart pins it, supplies the SealedSecret and values, and rolls it out when the pin merges. - Workload: a Deployment with one replica and the
Recreatestrategy. 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.arieldserves/healthzfor 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 inkube-systemand from pods labelledapp.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-forwardstill 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:
| Path | Disposition |
|---|---|
| Any pod calling prosperod on 7878 | Closed by the allow-list |
An agent sandbox in caliban calling prosperod | Closed: agents are not on the allow-list |
A person on 192.168.1.0/24 using the dashboard | Accepted. 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 router | Probably 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-forward | Accepted: a cluster admin already has full control |
| A compromised Traefik | Out 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
arielprincipal 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
| Ticket | Scope |
|---|---|
| caliban-ai/helm-charts#48 | gonzalo chart: principals file from an existing Secret |
| caliban-ai/helm-charts#49 | prospero chart: optional ingress NetworkPolicy with allowed clients |
| caliban-ai/ariel#30 | Image, release workflow, /healthz, file-based credentials |
| caliban-ai/helm-charts#50 | Generic ariel chart, disabled in the umbrella by default |
| caliban-ai/gonzalo#281 | Document GONZALO_AUTH_FILE |
| caliban-ai/caliban-operator#41 | Inject a gonzalod token into agent pods |
The private johnford2002/helm-charts rollout, tracked there rather than on the
caliban-ai board:
- Seal
ariel-credentialsand the gonzalod principals file. - Enable prosperod's NetworkPolicy with Traefik and Ariel as clients.
- Enable gonzalod auth and give agent workspaces their token.
- Pin and enable the
arielchart. - Confirm probes,
kubectl port-forwardand 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
Outputor 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
Outputand 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
Fleetfollows every workspace, including ones created later, so it never needs updating when a workspace is added.Workspacesfollows 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, butariel channel showwarns 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; aFleetchannel 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
| Channel | follows | notify | ceiling | Effect |
|---|---|---|---|---|
#ops | Fleet | Failures | Viewer | Hears only about failures across the whole fleet, including workspaces added later. Only read-only commands such as /ariel status work, even for admins. |
#caliban-team | Workspaces {caliban} | All | Operator | A 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:
| Field | Type | Required | Default |
|---|---|---|---|
provider | string, e.g. discord | yes | — |
tenant | string: guild, workspace or tenant ID | yes | — |
channel | string: platform channel ID | yes | — |
follows | tagged: { "kind": "fleet" } or { "kind": "workspaces", "names": [...] } with at least one name | yes | — |
notify | all, terminal or failures | no | all |
ceiling | viewer, operator or admin | no | viewer |
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:
followsis 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'skindwith another'snames.ceilingserializes capitalized ("Viewer"), becauseFleetRoleis 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
Fleetchannel 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
nextbranch 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-discorddepends on twilight. No twilight type appears inariel-core's public API; the backend converts at its boundary. - The backend owns its event loop: it reads Gateway events, turns interactions
into
Inboundevents 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-discordpins rustls 0.23 with theringprovider 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
ChatProvidertrait, 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'snextbranch 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::RateLimitedas ADR 0006 anticipates; ADR 0007's proactivesend_budgetpacing 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
seqper 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
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.
CommandSpechas amin_rolebut 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
authorizeitself, so no caller can skip it, with resultDenied. An unlinked account is recorded asFleetActor::Unlinked { authenticator, subject }, so attempts by unknown accounts stay visible. - An allowed command is recorded by
record_outcomeafter it runs, withSucceededorFailed, so the trail shows what actually happened. - The entry carries the action (
command.<name>), the target workspace (orfleet), 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 fleetviewergrant. "Mutating" is inferred frommin_role, so a future command that changes state but is open to viewers would escape the audit. An allowed command whose caller never callsrecord_outcomeleaves 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:
| Scope | Allows |
|---|---|
read | Every GET, including the agent event stream |
operate | read, plus spawn, kill, respawn, input, end-input and removing an agent |
admin | operate, 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
arieldauthenticates to prosperod with its own API token, namedariel, sent as a bearer header on every request. It arrives as a file named byARIEL_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
operatescope, the least that covers Ariel's commands: spawn and kill need it (#20), and notifications need onlyread. Ariel never needsadmin, since it does not change workspaces. - Without a token, requests carry none, so
arieldstill works against a prosperod with authentication off or older than v0.8. - A refused token is a distinct error. A
401or403becomesClientError::Auth, logged as an error namingARIEL_PROSPERO_TOKEN_FILE, because retrying cannot fix it. At startuparieldcallsGET /api/sessionand 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 asarielin 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
operatetoken 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
adminfor a workspace command.