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.

Two steps Aquila cannot do for you. There is no public endpoint for creating a Discord application, and automating the portal means driving a user account — self-botting, with real account-termination risk. So you create each application by hand (~40s each), one at a time, pasting each token as you go: a bot token is shown once and can only be recovered by resetting it. You also create the server yourself — Discord closed 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.

EntityWhat it isPer agentFixed by
Applicationwhat you create in the Developer Portaloneyou, by hand
Bot userhow the agent appears in DiscordoneDiscord — its id is the application id
Tokenthe credential that is the agent's identityoneDiscord, on reset
State dir~/.aquila/agents/<name>/oneAquila
Working directorywhere the session runsoneaquila move
Private channel#<name>, only this agent and youoneinit / add
Shared channelsrooms it joins alongside othersmanyaquila sync
Sessionthe Claude Code conversation it drivesone at a timeaquila 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.

#GateLives inFails as
1Can the bot see the channel?Discord permission overwrites403, never delivered
2Is the channel opted in?access.json → groups[id]dropped silently
3This sender, and mentioned if required?allowFrom, requireMentiondropped silently
Why an agent looks dead when it isn't. Gate 1 is Discord's and is the real security boundary — it's what makes per-agent channel isolation trustworthy. Gates 2 and 3 belong to the plugin and are both silent. aquila sync maintains gate 2; aquila allow maintains gate 3.

Where state lives

PathOwnerContents
~/.aquila/config.jsonAquilaagents, paths, ids, session ids, guest list
~/.aquila/agents/<name>/.envpluginDISCORD_BOT_TOKEN, mode 0600
~/.aquila/agents/<name>/access.jsonpluginwho, and which channels
~/.aquila/agents/<name>/auto-channels.jsonAquilawhich groups are Aquila's to retract
~/.aquila/agents/<name>/session.logAquilathe detached session's output
<workdir>/.claude/settings.local.jsonClaude Codepre-approved MCP tools
~/.claude/projects/<dir>/<uuid>.jsonlClaude Codeconversation 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

VariableEffect
AQUILA_HOMEwhere Aquila keeps config and state dirs. Defaults to ~/.aquila. Set it to run separate fleets side by side.
DISCORD_STATE_DIRset by Aquila per agent; what tells a session which agent it is. Not for you to set.
SSH_CONNECTION, DISPLAY, WAYLAND_DISPLAYread 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
FlagEffect
--webcollect tokens via a localhost form instead of terminal prompts
--port <n>port for --web (default 7777)
--renamealso set each bot's global username (~2/hour limit; the nickname is set regardless)
--adopttake over an existing channel with the agent's name, rewriting its permissions
--openopen install links in a browser; skipped automatically over SSH with no display
--no-pluginskip claude plugin install
--no-auto-channelsskip 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
FlagEffect
--trustrecord Claude Code's folder-trust for each agent's directory
--newstart a fresh conversation instead of resuming the bound one
--no-briefdon't tell the agent who the other agents are
--no-auto-channelsskip 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
FlagEffect
--offstop refreshing automatically; the command still works on demand
--onresume 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)
FlagEffect
--remove (--rm)take the named people off the list
--anyoneany member of the server may trigger agents
--owner-onlyonly you
This one has teeth. Triggering an agent makes a Claude Code session on your machine run a turn. It never applies to private channels — those stay yours whatever the guest list says. Ambiguous names list candidates rather than guessing, and bots are refused: the plugin drops inbound bot messages, so it would do nothing.

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

Turn off Public Bot. Discord's Public Bot toggle is on by default, so anyone who finds your application can install your agent on their own server. It is the gate ahead of everything else — enforced by Discord, before any 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.

Why DM and not the channel. A prompt carries the command or file path, which would leak to anyone who can read the channel. Upstream declined channel delivery for that reason. Note that --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.