Documentation
How Aquila's pieces map to each other, and what every command does. For why any of it works this way, the README carries the rationale.
Before you start
$ npm install -g @pid1lab/aquila
You need Bun — not just for Aquila, the
Discord channel plugin's own MCP server runs on it — plus Claude Code,
on Linux or macOS. package.json declares
"os": ["linux", "darwin"], so npm refuses to install on
Windows rather than failing confusingly at runtime.
POST /guilds to bots in July 2025. Both are covered on the
home page.
Concepts
One bot per agent, always. The mapping is 1:1 and Aquila never breaks it, so these docs say agent throughout and mean the bot too. Where the distinction genuinely matters — the Developer Portal, Discord's own API — they say so explicitly.
An agent is the durable thing. Everything else either belongs to it permanently or is a pointer it can be repointed at.
| Entity | What it is | Per agent | Fixed by |
|---|---|---|---|
| Application | what you create in the Developer Portal | one | you, by hand |
| Bot user | how the agent appears in Discord | one | Discord — its id is the application id |
| Token | the credential that is the agent's identity | one | Discord, on reset |
| State dir | ~/.aquila/agents/<name>/ | one | Aquila |
| Working directory | where the session runs | one | aquila move |
| Private channel | #<name>, only this agent and you | one | init / add |
| Shared channels | rooms it joins alongside others | many | aquila sync |
| Session | the Claude Code conversation it drives | one at a time | aquila bind |
The one N:M relationship is shared channels — many agents in many rooms. Everything else is 1:1.
Session is a pointer, not a property. An agent outlives
any particular conversation. aquila bind repoints it, and a
restart resumes whatever it points at rather than starting over.
What binds a session to an agent
A single environment variable:
DISCORD_STATE_DIR=/home/you/.aquila/agents/backend
A Claude Code session has no idea which agent it is. It inherits that
variable and passes it to the channel plugin, which reads the token and
access policy out of that directory. Which agent a session is =
which state dir it was pointed at. Not the working directory,
not the process, not config.json.
That is why claude --bg is unusable here: its daemon hands a
pre-warmed session an earlier invocation's environment, so a second agent
inherits the first one's state dir, both authenticate as the same bot, and
every message is answered twice. It is also why up strips
every CLAUDE* variable before spawning — a session that
inherits the launching session's identity stops being its own.
What has to be true for a message to reach an agent
Three gates, in order. Only the first announces itself.
| # | Gate | Lives in | Fails as |
|---|---|---|---|
| 1 | Can the bot see the channel? | Discord permission overwrites | 403, never delivered |
| 2 | Is the channel opted in? | access.json → groups[id] | dropped silently |
| 3 | This sender, and mentioned if required? | allowFrom, requireMention | dropped silently |
aquila sync maintains gate 2;
aquila allow maintains gate 3.
Where state lives
| Path | Owner | Contents |
|---|---|---|
~/.aquila/config.json | Aquila | agents, paths, ids, session ids, guest list |
~/.aquila/agents/<name>/.env | plugin | DISCORD_BOT_TOKEN, mode 0600 |
~/.aquila/agents/<name>/access.json | plugin | who, and which channels |
~/.aquila/agents/<name>/auto-channels.json | Aquila | which groups are Aquila's to retract |
~/.aquila/agents/<name>/session.log | Aquila | the detached session's output |
<workdir>/.claude/settings.local.json | Claude Code | pre-approved MCP tools |
~/.claude/projects/<dir>/<uuid>.jsonl | Claude Code | conversation transcripts |
config.json is a cache of Discord's state, not the
truth. Delete a channel in the Discord UI and its channelId
becomes a dangling pointer with nothing local noticing. The same applies
to pid, which is why status re-checks
/proc rather than trusting it.
Environment
| Variable | Effect |
|---|---|
AQUILA_HOME | where Aquila keeps config and state dirs. Defaults to ~/.aquila. Set it to run separate fleets side by side. |
DISCORD_STATE_DIR | set by Aquila per agent; what tells a session which agent it is. Not for you to set. |
SSH_CONNECTION, DISPLAY, WAYLAND_DISPLAY | read to decide whether --open can reach a browser |
Agents are spawned with every CLAUDE* variable stripped, so
a session never inherits the identity of whatever launched it.
Command reference
Every command. <required>,
[optional], ... repeats.
aquila init <agent...>
Provision bots and channels from scratch. Collects a token per agent,
enables the Message Content intent, discovers the server from the first
install, captures your snowflake from its owner_id, creates a
scoped private channel per agent, writes tokens and access policy, and
installs the channel plugin.
$ aquila init backend frontend # both in the cwd $ aquila init backend=~/src/api frontend=~/src/web # explicit dirs $ aquila init backend --web --port 8080 # tokens in a browser
| Flag | Effect |
|---|---|
--web | collect tokens via a localhost form instead of terminal prompts |
--port <n> | port for --web (default 7777) |
--rename | also set each bot's global username (~2/hour limit; the nickname is set regardless) |
--adopt | take over an existing channel with the agent's name, rewriting its permissions |
--open | open install links in a browser; skipped automatically over SSH with no display |
--no-plugin | skip claude plugin install |
--no-auto-channels | skip channel discovery for this run |
Agent names are lowercased, and anything outside
a–z 0–9 - _ becomes a dash — "My Agent!" is
my-agent. The name is the channel name and the bot's server
nickname, so it's worth typing the one you want. The application itself
can be called anything; Aquila sets a nickname to match.
aquila add <agent> [path]
One more agent on the server you already have. Same provisioning, minus server discovery — the install link comes pre-scoped to your guild, so it's one click. Path defaults to the cwd.
$ aquila add reviewer ~/src/docs
Takes every init flag except --no-plugin. A new
agent joins the shared channels the others are in before it first starts.
aquila up [agent...]
Start agents. Returns as soon as they're spawned; they outlive the shell, but not a reboot. No names means every agent.
Each runs detached with its own pty and its own
DISCORD_STATE_DIR. up refreshes channel
discovery, resumes each agent's bound conversation, appends the roster
briefing, and waits for the Discord gateway before reporting success — a
session that starts fine and is deaf is the failure worth catching.
$ aquila up · backend starting (pid 2435922, resuming) · frontend starting (pid 2435924, resuming) ✓ backend connected → #backend ✓ frontend connected → #frontend
| Flag | Effect |
|---|---|
--trust | record Claude Code's folder-trust for each agent's directory |
--new | start a fresh conversation instead of resuming the bound one |
--no-brief | don't tell the agent who the other agents are |
--no-auto-channels | skip channel discovery for this run |
Refuses to start an agent twice: two sessions on one token answer every message twice.
aquila down [agent...]
Stop agents. SIGTERMs the process group, waits for it to actually die, and escalates to SIGKILL if it doesn't — it verifies rather than assuming.
$ aquila down $ aquila down frontend
aquila status
Agents, channels, bound sessions, and liveness. Also prints the server, your snowflake, and who can trigger agents in shared channels.
server: 1473397975479881830 owner: 1021487254104973352 trigger: you only AGENT CHANNEL STATE SESSION PATH backend #backend connected · pid 2435922 368592fb /home/you/src/api frontend #frontend stopped c5ae1100 /home/you/src/web
connected means the gateway is up; no gateway
means the session is running but deaf. A running agent whose directory has
drifted from config is flagged ⚠ running in …. Takes
--no-auto-channels.
aquila sessions [agent...]
Conversations recorded for each agent's working directory, newest first.
● marks the one the agent is bound to.
backend → /home/you/src/api
● 368592fb 136 turns 253K 23m ago "Ping"
f101de6d 23 turns 49K 8h ago "run whoami and tell me the output"
The preview is the first thing you said, with the Discord envelope stripped. Turn count and size are shown because a resumed session grows until it compacts.
aquila bind <agent> <session-id>
Point an agent at a different conversation. Abbreviated ids work, as
printed by aquila sessions. Takes effect on the next start,
since the session is fixed at spawn.
$ aquila bind backend 368592fb # adopt a terminal conversation $ aquila bind backend --new # start a fresh one
aquila sync [agent...]
Opt each agent into every channel its bot can see, and retract channels it
has lost access to. Runs automatically during init,
add, up and status; the command
forces it after you create a channel.
$ aquila sync + backend joined #general + backend joined #test + frontend joined #general + frontend joined #test
| Flag | Effect |
|---|---|
--off | stop refreshing automatically; the command still works on demand |
--on | resume automatic refresh, and sync now |
Groups you added to access.json by hand are never modified or
retracted. Agents never join each other's private channels. Takes effect
on running agents without a restart — the plugin re-reads
access.json per message.
aquila allow [name...]
Who besides you can trigger an agent in a shared channel. No arguments lists the current policy. Names in, names out — @handles, display names, server nicknames, or a raw user id.
$ aquila allow Can trigger agents in shared channels: xuhengli. (owner) trz (trz6782)
| Flag | Effect |
|---|---|
--remove (--rm) | take the named people off the list |
--anyone | any member of the server may trigger agents |
--owner-only | only you |
aquila move <agent> <path>
Change an agent's working directory. Trust-checks the destination, carries over tool pre-approval, updates config, and restarts the agent if it was running.
$ aquila move backend ~/src/new-api $ aquila move backend ~/src/new-api --trust
Editing path in config.json by hand does none of
that — the running session keeps its old directory while
status reports the new one.
aquila set <agent> <key>=<value>
Change a setting. Currently claudeArgs, extra flags for the
claude invocation, split on whitespace.
$ aquila set backend claudeArgs=--model opus $ aquila set backend claudeArgs= # clear
Read at spawn, so it warns when a restart is needed. path is
redirected to move rather than done halfway.
What agents are told
up appends a short briefing to each session's system prompt:
the agent's own name and private channel, the shared channels it's in,
and the other agents with their working directories.
--no-brief skips it.
The roster is the pleasant half. The half that matters is a constraint that bites the moment an agent learns siblings exist — it will try to delegate by @mentioning one, and the plugin drops bot-authored messages, so nothing is delivered and nothing errors. The agent is then free to report a handoff that never happened, so it's told outright that it cannot pass work along and should not claim to.
Agents can still read each other: channel history comes back unfiltered, including other agents' replies. Collaboration works with you as the scheduler — ask one agent something, then ask another to build on it.
The briefing is fixed at spawn and rebuilt on every up, so an
agent already running when you add another won't know about
the newcomer until it restarts.
Permissions and approvals
access.json policy applies. Aquila
cannot set it: PATCH /applications/@me accepts the request
and persists every other field while silently dropping
bot_public. Deliberate, and correct — a leaked bot token
shouldn't be able to make your bot installable everywhere — but it means
the toggle is portal-only. init, add and
status check it and tell you.
Each agent's working directory gets a
.claude/settings.local.json pre-approving the channel
plugin's own MCP tools. Without it the agent needs permission to call
reply — permission to speak on Discord at all — so every
message becomes an approval prompt. Existing settings are merged, never
replaced.
Beyond that, Claude Code auto-approves read-only commands
(ls, git log, whoami), so prompts
fire only for consequential actions: writes outside the workspace,
network calls, destructive commands. Those arrive as a
DM with Allow / Deny / See more, independently
authenticated against the allowlist.
--dangerously-skip-permissions does not cover MCP
tools.
First run in a new directory also needs Claude Code's workspace trust.
There's no CLI flag for it and a detached session blocks forever on a
prompt nobody can see, so up checks first and refuses with
instructions; --trust records it. Deliberately explicit —
trusting a folder lets Claude Code read, edit and execute everything in
it.