Aphid
A fast and hackable agent harness.
Aphid is a coding agent written in Rust around a data-oriented core. A conversation lives in flat, append-only arenas. Streaming deltas are resolved with one memory copy. Each stage — the request, the stream, the tool call, the permission question — is a plugin hook that you can watch, stop, or rewrite.
This book is written in Simplified Technical English.
Highlights
- Almost no memory copies. A turn is staged in the arenas of a message buffer, and committed into the transcript with one copy for each arena, whatever quantity of tokens arrived. The layout rules are applied when aphid is compiled. See Core.
- Data-oriented design. Spans, and not owned strings. A full session is a small quantity of allocations, released together.
- A fast start. The command-line tool is thin. Discovery finds the
workspace, its
AGENTS.mdinstructions and its skills before the agent starts. - Fully debuggable.
aphid rawprints each protocol event as it occurs, andaphid raw --requestprints the encoded request body. - Extensible with plugins. Each interesting point is a synchronous hook. See Plugins.
What it looks like
$ aphid
This opens the terminal user interface. A prompt runs one time and prints the result:
$ aphid -p "what does this crate do?"
$ aphid "what does this crate do?"
Getting started tells you how to install aphid and how to give it a key.
The five front ends
aphid [OPTIONS] open the terminal user interface
aphid [OPTIONS] -p <prompt> run one prompt, and print the result
aphid alate <command> run a resident agent, or attach a terminal to one
aphid raw [OPTIONS] <prompt> stream one completion, and print each protocol event
aphid agent [OPTIONS] <prompt> run the agent loop with a demo tool
aphid model <command> manage the models in ~/.aphid/models.json
The first two are the coding agent, which Aphid describes with each
of its options. alate is the resident agent, which Alate
describes. raw, agent and model are also in the
Aphid chapter.
How the code is arranged
The workspace is eight crates, and each one is a narrow step above the one before it:
| Crate | What it holds |
|---|---|
aphid-core | The message, model and streaming types. See Core. |
aphid-agent | The agent loop, the tool registry and the plugin API. |
aphid-plugin | The Rhai host: discovery, the script engine, the capabilities and the trust gate. |
aphid-code | The coding specialization: the tools, the prompt, the skills, the sessions and the terminal user interface. See Aphid. |
aphid-alate | The resident agent: a home, a memory, a heartbeat and a gateway. See Alate. |
aphid-nostr | NIP-01 and NIP-29, with no socket and no clock in it. See Colony. |
aphid-colony | The hub agents speak to each other in: a relay, a store and a terminal. See Colony. |
aphid-cli | The thin aphid binary, which connects the six front ends. |
aphid-agent is deliberately without opinions: it runs request → stream →
commit → execute tools until the model stops asking for tools. Everything that
makes aphid a coding agent is in aphid-code, and an alate builds its agent
with that same harness, without a change.
For the Rust API of any crate, use cargo doc --open.
Licence
Aphid is licensed under the MIT Licence. The LICENSE file in the repository
gives the full text.
Getting started
This chapter tells you how to build aphid, how to give it a key, and how to run it for the first time.
What you need
- A Rust toolchain of the 2024 edition or later. Aphid is built with
rustc1.94. - An API key for a model. The models that aphid supplies are DeepSeek models,
and they read
DEEPSEEK_API_KEY. To use a different provider, refer to Add a model. - A system with Unix sockets, if you want the resident agent.
aphid alatedoes not work on Windows. The coding agent does.
Install
The installer gets the binary of the last release and puts it in
~/.local/bin. It is the fastest way, because it compiles nothing:
$ curl --proto '=https' --tlsv1.2 -LsSf https://github.com/tncardoso/aphid/releases/latest/download/aphid-ai-installer.sh | sh
The releases hold binaries for Linux and macOS. On other systems, and on a different processor, cargo compiles it from the registry:
$ cargo install aphid-ai
Build from the source
$ git clone https://github.com/tncardoso/aphid
$ cd aphid
$ cargo build --release
The binary is then target/release/aphid. To put it on your path:
$ cargo install --path crates/aphid-cli
There is one optional feature, telegram, which adds a Telegram bot to the
resident agent and an HTTP client to the build. It is not on by default, because
a build with no bot does not need the HTTP client.
$ cargo install --path crates/aphid-cli --features telegram
Give it a key
$ export DEEPSEEK_API_KEY=sk-...
Put this line in the file that your shell reads at start, so that each terminal has it.
Each model gives the name of the variable that holds its key, and aphid reads the variable of the model that you selected. Thus a model from a different provider reads a different variable, and a key that is absent is reported by name:
$ aphid --model glm-5 -p "hello"
aphid: ZHIPU_API_KEY is not set, and glm-5 needs it
The first run
Go to a repository and start the terminal user interface:
$ cd ~/projects/my-project
$ aphid
Type a question and press Enter. Type /help to see the
commands.
To run one prompt and print the result, give the prompt on the command line:
$ aphid -p "what does this crate do?"
Aphid records each session, and it records the headless runs also. aphid --sessions lists them, and aphid --resume continues the most recent one.
Add a model
The catalogue is the models that aphid supplies, and then your own models in
~/.aphid/models.json. The descriptions come from
models.dev, so you do not write out a context window and a
price by hand.
$ aphid model search glm --limit 3
$ aphid model add zhipuai/glm-5
$ aphid --model glm-5 -p "hello"
Aphid describes each model subcommand, and
Core describes the file that they write.
Tell it about your project
Aphid reads each AGENTS.md file from the root of the workspace down to the
current directory, and the most specific file has the final word. Put the
conventions of the project in one:
# AGENTS.md
- Run `cargo clippy` and `cargo fmt` after each change.
- The tests are in `tests/`, and each one is a file.
A file at ~/.aphid/AGENTS.md is applied in each workspace.
For instructions that are only needed sometimes, write a skill instead. A skill costs almost nothing until the model opens it.
Start a resident agent
The coding agent starts in a repository and forgets everything when you close the terminal. An alate has a home of its own, a memory, and a clock that wakes it.
$ aphid alate run --name work
aphid: work is awake in /home/you/.aphid/alate/work
aphid: attach with `aphid alate attach --name work`
Attach a terminal to it from somewhere else, and detach again with Ctrl-C. The
alate continues to run. Alate describes the home, the memory, the
heartbeat and the crontab.
Where things are kept
| Path | Content |
|---|---|
~/.aphid/models.json | Your models. |
~/.aphid/AGENTS.md | Instructions for each workspace. |
~/.aphid/skills/ | Your skills, for each workspace. |
~/.aphid/plugins/ | Your plugins, for each workspace. |
~/.aphid/alate/<name>/ | One resident agent. |
<workspace>/AGENTS.md | Instructions for one workspace. |
<workspace>/.aphid/sessions/ | The saved sessions. |
<workspace>/.aphid/skills/ | The skills of this workspace. |
<workspace>/.aphid/plugins/ | The plugins of this workspace. |
APHID_HOME replaces ~/.aphid. Use it to keep a separate configuration.
Build and test the source
$ cargo build
$ cargo test
$ cargo clippy
$ cargo fmt
$ cargo build --features telegram
$ cargo test -p aphid-alate --features telegram
aphid raw and aphid agent can be fully scripted. Their tests run the full
encode, stream and commit path against a model that is not on the network.
Core — the AI layer
aphid-core is the layer below every front end. It holds the message types, the
model catalogue and the streaming code, and it knows nothing about tools,
plugins or terminals.
This chapter tells you what the layer does and which files you can edit. For the
Rust API, use cargo doc -p aphid-core --open.
The transcript
A conversation is a transcript: a flat list of messages over two append-only arenas, one for text and one for binary data.
A content block holds a span — a range of bytes in an arena — and not an owned string. Thus a full session is a small quantity of allocations, which are released together when the transcript is released. No lifetime goes out into the code that uses the crate: a transcript is one owned value that you can move between threads.
Spans stay inside the crate. Everything is read through views, which resolve a range against the arena and give back a plain string.
The system prompt is not special. It is a message with the system role. The map from that to the wire format is the work of an encoder.
Why it is arranged like this
Streaming is where the layout is of use. A provider collects a reply in a message buffer, which has arenas of its own. Each delta is added to the tail of an arena one time, and the event that reports it carries only the span of the bytes that were written. To commit the turn, aphid moves the finished buffer across with one memory copy for each arena — whatever quantity of tokens arrived.
The rules of the layout are applied when aphid is compiled: a span is 8 bytes, a content block is not more than 24, an event is not more than 16, and a message header is not more than 32. A change that makes one of these larger does not build.
The transcript only grows. A plugin adds to it, and cannot rewrite it.
The wire
Aphid speaks the OpenAI chat-completions protocol, and no other. The stream is server-sent events.
This has one result that you see: a provider that speaks a different protocol
cannot be added to the catalogue. aphid model add refuses such a model, and
says so.
Almost every provider says that it is “OpenAI-compatible”, and each one is compatible in a slightly different way. Aphid states these differences as a compatibility profile on the model, and not as a guess made from the address at the time of the request.
| Profile | Use |
|---|---|
compatible | A different company’s OpenAI-compatible server. The default. |
openai | OpenAI and Azure. |
deepseek | DeepSeek. |
none | No behaviour table. |
A profile holds the answers to questions that models.dev cannot answer, because
they are about the server and not about the model: which field limits the length
of the answer, whether the endpoint accepts reasoning_effort, whether a tool
result must repeat the name of the tool, whether a user message can come
directly after a tool result, and approximately twelve more.
A model gives the name of a profile, and then each behaviour that is different from that profile. Thus a correction is usually one line. Refer to The catalogue.
Thinking levels
Aphid has one ladder of levels for each model that can reason:
off minimal low medium high xhigh max
off is not a level. It removes the reasoning fields from the request.
Each model supplies a different set of levels. If you ask for a level that the model does not supply, aphid decreases it to the nearest level that the model does supply, and prints a note. If the model cannot reason at all, aphid ignores the level and prints a note.
The coding agent starts at medium. --think and the /think command change
it, and thinking in alate.json sets it for a resident agent.
The catalogue
The catalogue is the models that aphid supplies, and then the models in
~/.aphid/models.json. A model in the file with the same identifier as one that
aphid supplies replaces it. Thus aphid works with no configuration at all.
Aphid supplies deepseek-v4-flash and deepseek-v4-pro, and both read
DEEPSEEK_API_KEY.
aphid model add writes this file for you, from the description on
models.dev. Refer to model for the
commands.
models.dev
Aphid keeps a copy of the models.dev document in ~/.aphid/models.dev.json, and
it uses the copy while the copy is less than 24 hours old. aphid model update
gets the document again.
If aphid cannot get the document, and a local copy exists, aphid uses the local copy and tells you that the data is old. An old price is more useful than an error.
The file
~/.aphid/models.json is a file that you can edit. Each model looks like this:
{
"version": 1,
"models": [
{
"id": "glm-5",
"name": "GLM-5",
"provider": "zhipuai",
"api": "openai-completions",
"base_url": "https://open.bigmodel.cn/api/paas/v4",
"api_key_env": "ZHIPU_API_KEY",
"reasoning": true,
"input": ["text"],
"context_window": 204800,
"max_tokens": 131072,
"cost": { "input": 1.0, "output": 3.2, "cache_read": 0.2, "cache_write": 0.0 },
"compat": { "profile": "compatible", "supports_reasoning_effort": false }
}
]
}
A model needs an id, a base_url, a context_window and a max_tokens. All
the other fields have defaults.
In the example above, the endpoint is a usual OpenAI-compatible server, but it
refuses the reasoning_effort field. That is the whole of the correction.
thinking_levels gives the value to send for each level. A text value is the
value to send. false means that the model refuses the level. If a level is not
in the file, aphid sends the name of the level.
"thinking_levels": { "off": "disabled", "minimal": "low", "max": "max", "xhigh": false }
If aphid cannot read the file, it prints the problem and continues with the models that it supplies. A mistake in this file cannot prevent a start.
Looking at the protocol
aphid raw and aphid agent print what this layer does, in place of the text:
$ aphid raw --request "hello" # the encoded request body, with no key
$ aphid raw --events --tool "what is the weather in Lisbon?"
--events prints each delta event with its span, which is the layout of this
chapter made visible. Refer to raw and agent.
Aphid — the AI harness
The coding agent is what aphid runs when you give it no subcommand. It is the
agent loop of aphid-agent, with everything a coding agent needs put around it:
the tools, a system prompt made from the conventions of the project, the skills,
the sessions and the permission gate.
This chapter tells you what the harness does, and gives each option of the command. The Commands, Skills and Plugins chapters describe the three parts that you control.
The workspace
Aphid finds the workspace when it starts. This is the root of the repository, or the directory that you are in when there is no repository.
The read, write and edit tools can touch only this directory. The bash
tool is not limited in this manner, because a shell reads and writes anywhere.
| Tool | Effect |
|---|---|
bash | Runs a command. Not limited to the workspace. |
read | Reads a file, or a part of one. |
write | Writes a full file. |
edit | Replaces text in a file. |
The output of a tool is cut when it is very long, and the full output is kept in a file that the message gives the name of.
The instructions
Aphid reads each AGENTS.md file from the root of the workspace down to the
current directory. The most specific file is last, and it has the final word. A
file at ~/.aphid/AGENTS.md is read before all of them, and thus is applied in
each workspace.
Put the conventions of the project in these files: how to run the tests, how to write a commit message, what not to touch.
For instructions that are only necessary sometimes, write a skill. A skill costs almost nothing until the model opens it.
--no-context stops aphid from reading the AGENTS.md files and the skills.
Sessions
Aphid records each session as one file of JSON lines in
<workspace>/.aphid/sessions, and adds to it as each message is committed.
Nothing is written a second time. Thus a failure costs the turn that was in
flight and no more, and --resume is a replay of the file.
Headless runs are recorded also, and --sessions and --resume see them in the
same manner as the sessions of the terminal.
$ aphid --sessions # print the saved sessions
$ aphid --resume # continue the most recent session here
$ aphid --resume 20260810T012035-0000 # continue the session with this identifier
The identifier is optional. If you give no identifier, aphid continues the most recent session for the current directory.
Permissions
--confirm makes aphid ask you before it runs a command that changes the
workspace. A headless run has no terminal for a question. Thus --confirm and
-p together refuse each such command, and do not permit it quietly.
A plugin can answer these questions in place of you, with the on_permission
hook. Refer to Plugins.
Invocation
aphid [OPTIONS] [PROMPT]... the coding agent
aphid alate <COMMAND> run a resident agent, or attach to one
aphid raw [OPTIONS] <PROMPT>... stream one completion, and print each protocol event
aphid agent [OPTIONS] <PROMPT>... run the agent loop with a demo tool
aphid model <COMMAND> manage the models in ~/.aphid/models.json
The coding agent is the default. If the first word is alate, raw, agent or
model, aphid runs that subcommand. If the first word is something different,
aphid uses the full command line as a prompt for the coding agent.
Give a prompt to run the agent one time. Give no prompt to open the terminal user interface.
$ aphid # opens the terminal user interface
$ aphid -p "fix the failing test" # runs one time, and prints the result
$ aphid "fix the failing test" # the same, with no -p
-p and the bare words do the same thing. Only an empty prompt opens the
terminal user interface.
The options
| Option | Effect |
|---|---|
-p, --print <PROMPT> | Run one time. Stream the result to stdout, and exit. |
--model <NAME> | Select a model. Give the identifier, or a unique part of it. |
--models | Print the known models, and exit. |
--think <LEVEL> | Set the quantity of reasoning. |
--system <TEXT> | Replace the standard instructions. |
--append-system <TEXT> | Add text to the instructions. |
--resume [<ID>] | Continue a saved session. |
--sessions | Print the saved sessions for this workspace, and exit. |
--confirm | Ask before each command that changes the workspace. |
--no-context | Do not read AGENTS.md files or skills. |
--list-plugins | Print the plugins that would load, and exit. |
--no-plugins | Do not load any plugin from .aphid/plugins. |
--plugin <PATH> | Load one plugin from a path. |
--trust-plugins | Agree to the plugins of this workspace. |
--max-turns <N> | Stop the run after this quantity of requests. |
--quiet | Do not print the output of each tool. |
Select a model
--model accepts the full identifier, the last part of it, or a prefix. Aphid
tries these three forms in that sequence. If two or more models match, aphid
refuses the name and prints the models that matched.
$ aphid --model deepseek-v4-pro -p "hello" # the full identifier
$ aphid --model pro -p "hello" # the last part
If you give no --model, aphid uses the first model in the catalogue.
--models prints the catalogue. The catalogue contains the models that aphid
supplies and the models in ~/.aphid/models.json. To add a model, refer to
model.
Set the quantity of reasoning
--think accepts these levels: off, minimal, low, medium, high,
xhigh and max. medium is the default.
Each model supplies a different set of levels. Aphid decreases the level to the nearest level that the model supplies, and prints a note. If the model cannot reason, aphid ignores the option and prints a note. Refer to Thinking levels.
Control the plugins
The plugin options control the Rhai plugins in .aphid/plugins. A plugin in your
home directory always loads. A plugin that comes with a workspace needs your
agreement the first time; aphid asks before the terminal user interface starts,
and keeps the answer in ~/.aphid/trust.json. A headless run has no terminal for
a question, and thus does not load the plugins of the workspace unless you give
--trust-plugins. --plugin names a file directly and does not ask.
Use --no-plugins to make the start of a run fully predictable. Read
Plugins to write one.
alate
aphid alate runs a resident agent. An alate has a home directory of its own, a
memory that continues between sessions, a clock that wakes it, and a socket that
a terminal attaches to.
aphid alate run [--name NAME] run the alate in this terminal
aphid alate attach [--name NAME] open a terminal on a running alate
aphid alate list show the alates on this machine
| Option | Effect |
|---|---|
-n, --name <NAME> | Select the instance. The default is default. |
run holds the terminal until you stop it. attach opens a terminal on an
alate that already runs; close it, and the alate continues.
Alate gives the home directory, each field of the configuration, the memory, the heartbeat and the crontab. CLI gives the terminal that attaches.
aphid alate needs a Unix socket, so it does not work on Windows.
raw and agent
These two subcommands are the debug tools. raw sends one request. agent
loops until the model stops to call tools. Both accept the same options.
| Option | Effect |
|---|---|
--pro | Use deepseek-v4-pro. The default is deepseek-v4-flash. |
--system <TEXT> | Put a system message before the prompt. |
--think <LEVEL> | Set the quantity of reasoning. |
--max-tokens <N> | Limit the length of the response. |
--temperature <F> | Set the sampling temperature. |
--tool | Supply a demo get_weather tool, to show tool-call deltas. |
--events | Print each delta event with its span, in place of the text. |
--request | Print the encoded request body, and exit. |
--request does not send a request. Thus you can use it with no API key.
$ aphid raw --request "hello" # print the request body
$ aphid raw --events --tool "what is the weather in Lisbon?"
These two subcommands always use a DeepSeek model, and they always read
DEEPSEEK_API_KEY. To use a different model, use the coding agent.
model
aphid model manages ~/.aphid/models.json. The command aphid models does
the same thing. Core describes the catalogue and the
format of the file.
The model descriptions come from models.dev. Aphid keeps a
copy of that document in ~/.aphid/models.dev.json, and it uses the copy while
the copy is less than 24 hours old.
model add
aphid model add [OPTIONS] <NAME>
<NAME> is provider/model, or a model identifier that only one provider
supplies.
$ aphid model add zhipuai/glm-5
added glm-5 in /home/you/.aphid/models.json
provider zhipuai
endpoint https://open.bigmodel.cn/api/paas/v4
limits 204800 context · 131072 output
price $1.00 in · $3.20 out per M tokens
key $ZHIPU_API_KEY
(cached 3h ago; `aphid model update` to refresh)
Many providers supply a model with the same identifier. If the name is ambiguous, aphid prints each provider that supplies that model:
$ aphid model add deepseek-v4-pro
aphid: `deepseek-v4-pro` is served by 23 providers:
alibaba-cn/deepseek-v4-pro
azure/deepseek-v4-pro
...
Name one of them, or pass --provider <id>.
Some model identifiers contain a slash. Aphid reads the full name as a model
identifier first, and as provider/model second. Thus both of these commands
find the same model:
$ aphid model add openai/gpt-oss-120b
$ aphid model add wandb/openai/gpt-oss-120b
| Option | Effect |
|---|---|
--provider <ID> | Use only this provider. Use it when a name is ambiguous. |
--base-url <URL> | Give the endpoint URL. models.dev does not list one for each provider. |
--api <API> | Set the wire protocol. |
--api-key-env <VAR> | Set the environment variable that holds the API key. |
--compat <PROFILE> | Set the endpoint behaviour. Refer to Core. |
--force | Replace a model that is already in the catalogue. |
--refresh | Get the models.dev document again, even if the copy is new. |
--offline | Use the local copy only. Fail if there is no copy. |
Aphid speaks the OpenAI chat-completions protocol only. If the provider speaks a
different protocol, aphid refuses the model. --api openai-completions makes
aphid add the model regardless.
model remove
aphid model remove <NAME>
<NAME> accepts the same three forms as --model. This command removes a model
from ~/.aphid/models.json only. It cannot remove a model that aphid supplies.
$ aphid model remove glm-5
removed glm-5 from /home/you/.aphid/models.json
model list
aphid model list [--all]
This command prints the models in ~/.aphid/models.json. --all prints the
models that aphid supplies also, and gives the source of each model.
model search
aphid model search [OPTIONS] <QUERY>
This command finds models on models.dev, but it adds no model. Aphid compares the query with the provider identifier, the model identifier and the model name.
$ aphid model search glm --limit 3
302ai/glm-4.5 131072 ctx $ 0.29/$1.14 GLM-4.5
zhipuai/glm-5 204800 ctx $ 1.00/$3.20 GLM-5
...
(cached 3h ago; `aphid model update` to refresh)
| Option | Effect |
|---|---|
--limit <N> | Print at most this many results. The default is to print them all. |
--refresh | Get the models.dev document again. |
--offline | Use the local copy only. |
Each name in the first column is a name that aphid model add accepts.
model update
aphid model update
This command gets the models.dev document again, and writes it to
~/.aphid/models.dev.json. Then it prints the quantity of providers and models,
and the models that models.dev added or removed after the previous copy.
$ aphid model update
/home/you/.aphid/models.dev.json · 182 providers · 6243 models · 3.5 MB
3 added:
deepseek/deepseek-v4-pro
...
This command changes the local copy only. It does not change
~/.aphid/models.json.
add and search also get the document if the local copy is more than 24 hours
old. Use model update to get the document immediately.
To correct a model by hand, refer to The file.
Files and environment variables
| Path | Content |
|---|---|
~/.aphid/models.json | Your models. |
~/.aphid/models.dev.json | The local copy of the models.dev document. |
~/.aphid/AGENTS.md | Instructions for each workspace. |
<workspace>/AGENTS.md | Instructions for one workspace. |
<workspace>/.aphid/sessions/ | The saved sessions. |
<workspace>/.aphid/skills/ | The skills of this workspace. |
<workspace>/.aphid/plugins/ | The plugins of this workspace. |
~/.aphid/skills/ | Your skills, for each workspace. |
~/.aphid/plugins/ | Your plugins, for each workspace. |
~/.aphid/trust.json | The workspaces whose plugins you agreed to. |
~/.aphid/alate/<name>/ | One resident agent. See Alate. |
| Variable | Effect |
|---|---|
APHID_HOME | Replaces ~/.aphid. Use it to keep a separate configuration. |
DEEPSEEK_API_KEY | The key for the models that aphid supplies. |
APHID_HOME moves the model catalogue, the trust file and the alates. It does
not move AGENTS.md, the skills or the plugins of your home directory: those
follow HOME, so that a separate catalogue does not take your instructions away
with it.
Each model gives the name of the variable that holds its key. The coding agent reads the variable of the model that you selected. Thus a model from a different provider reads a different variable:
$ aphid --model glm-5 -p "hello"
aphid: ZHIPU_API_KEY is not set, and glm-5 needs it
If you change the model in the terminal user interface, aphid reads the key of the new model.
Exit codes
| Code | Meaning |
|---|---|
0 | Success. |
1 | The run failed, or aphid could not read or write a file or the network. |
2 | The command line was wrong. |
Commands
A command is a line that starts with /. The terminal user interface reads it
and acts on it. A command never goes to the model, unless a plugin decides to
send something to the model itself.
Type /help to see the list in the terminal.
The standard commands
| Command | Effect |
|---|---|
/model [name] | Change the model, or open the picker when you give no name. |
/think <level> | off, minimal, low, medium, high, xhigh or max. |
/clear, /new | Start a new conversation. The system prompt stays. |
/tools | List the tools that are registered. |
/ps | Show what the runtime runs now, and what it ran before. |
/session | Show where this session is written. |
/plugins | List the plugins that loaded, and the commands they added. |
/skills | List the skills that the model can open. |
/help | Print the list. |
/quit | Exit. /q and /exit do the same. |
| Key | Effect |
|---|---|
Esc | Stop the run. |
Ctrl-C | Quit. |
Ctrl-P | Change to the next model. |
Ctrl-T | Show the reasoning. |
PageUp, PageDown | Scroll. |
/model with no name opens a list of the catalogue. /model <name> accepts the
same three forms as --model: the full identifier, the last part of it, or a
prefix.
/clear and /new are the same command. The conversation is dropped and the
system prompt is kept, so the agent still knows the project.
/ps
The list shows each command that runs now, and the last four commands that
stopped. Each line gives the number of the command, its system process
identifier, the source (bash, or the name of a plugin), the time, and, for a
command that stopped, the result and the quantity of output.
Press the arrow keys to select a command that runs now, and press k to stop
it. This stops the command and each command that it started. Press Esc to
close the list.
The list opens while the agent runs also, which is when there is most to see. The other commands wait for the run, because they speak to the agent; this one does not.
Commands from plugins
A plugin adds a command with register_command, at the top level of the file.
The command shows in /plugins.
register_command(#{
name: "review",
description: "Ask for a review of the changes.",
run: |args| {
let diff = exec("git diff").stdout;
if diff == "" { return notice("nothing to review"); }
prompt("Review this diff:\n" + diff);
notice("reviewing…")
}
});
args is the text after the name of the command.
Return notice(text), a text, or an array of them to show text to the user. To
send text to the model, call prompt(text). Aphid shows the notices first, and
then the prompt, whatever the order in the command.
A standard command always wins, and thus a plugin cannot take /quit away. If
two plugins use one name, aphid keeps both: the second becomes /review:2.
A name with a space in it is refused. A leading / is removed, so review and
/review give the same command.
Refer to Plugins for the rest of what a plugin can do.
The resident agent
The terminal that attaches to an alate has a different, smaller set of commands. Refer to CLI.
Skills
A skill is an instruction file that the model opens when it needs it.
Only the name, the description and the path of each skill go into the system
prompt. The model reads the body with the read tool when a task agrees with
the description. This is progressive disclosure, and it is what keeps twelve
skills from costing twelve skills’ worth of context on each request.
Use an AGENTS.md file for what is true always. Use a skill for what is true
sometimes: how to make a release, how to add a migration, how to write a
particular kind of test.
Where skills go
Aphid looks in the workspace first, and then in your home directory. Two layouts are correct:
.aphid/skills/<name>/SKILL.md
.aphid/skills/<name>.md
Use the directory when the skill has files of its own — a script, a template, an example. The model can read them, because you give it the path.
A skill in the workspace hides a skill in your home directory with the same name. Thus a project can replace a skill that you carry everywhere.
Writing one
A skill needs frontmatter: a --- block at the top of the file, with flat
key: value lines.
---
name: release
description: How to cut a release of this crate. Use when the user asks to release, tag or publish.
---
# Release
1. Make sure that `cargo test` passes on `main`.
2. Change the version in `Cargo.toml`.
...
| Field | Effect |
|---|---|
description | Necessary. What the skill is for, and when to use it. |
name | The name of the skill. Optional. |
The description is the whole of what the model sees before it opens the file.
Write it to say when to use the skill, and not only what it is. A description
of more than 1024 characters is refused, and the skill is reported.
If there is no name, aphid uses the name of the directory for a SKILL.md, or
the name of the file for a loose .md.
Aphid reads only the two keys above. Frontmatter with more in it is accepted, and the rest is passed over.
Looking at them
Type /skills in a session. Each line gives the name of the skill, its
description, and whether the skill comes from the workspace (project) or from
your home directory (global).
A line that starts with ! is a skill file that aphid could not use, and the
reason: no description, a description that is too long, or a file that could not
be read. A skill file with a mistake in it is reported, and the session
continues.
--no-context stops aphid from reading the skills and the AGENTS.md files.
In a resident agent
An alate reads the skills in <home>/.aphid/skills, in the same manner. The
home of the alate is its workspace, so this is the workspace layout and not a
special one. Refer to Alate.
Plugins
A plugin is one file of Rhai code. It can look at a run, stop a tool, change a prompt, add a tool, and add a command. You do not compile aphid again to add one.
A plugin can also be written in Rust, and compiled in. Refer to Plugins in Rust.
Where plugins go
Aphid looks in the workspace first, then in your home directory. Two layouts are correct:
.aphid/plugins/<name>.rhai
.aphid/plugins/<name>/main.rhai
The name of the plugin is the name of the file, or the name of the directory. A plugin in the workspace hides a plugin in the home directory with the same name.
Write the description of the plugin in //! comment lines at the top of the
file. The /plugins command and aphid --list-plugins show this text.
//! Keeps the model away from the changelog.
fn on_tool_call(tool) {
if tool.name == "write" && tool.arguments.contains("CHANGELOG") {
return block("the changelog is written by hand");
}
}
Important: call is a reserved word in Rhai. Do not use it as the name of a
parameter.
Trust
A plugin in your home directory always loads. It is yours.
A plugin in a workspace comes with the checkout, so aphid asks you before it
loads one for the first time. Aphid keeps your answer in ~/.aphid/trust.json
and does not ask again for that workspace.
Aphid asks the question before the terminal user interface starts. In headless
mode aphid does not ask, and does not load the plugins of the workspace. Use
--trust-plugins to agree without a question.
This controls which plugins load. It does not control what a plugin that loaded can do. A plugin that you agreed to can do all that you can do.
Hooks
To add a hook, write a function with the correct name. Aphid reads the names when it loads the file. A plugin pays only for the hooks that it has.
These hooks come from the agent loop:
| Function | When it runs |
|---|---|
on_prompt(draft) | Before aphid puts your prompt in the transcript |
on_run_start(cx) | The run starts |
on_turn_start(cx) | Before each request to the model |
on_event(event) | For each protocol event. This is the fast path |
on_message(cx, message) | After the answer of the model is in the transcript |
on_tool_call(tool) | A tool call is asked for, but did not run |
on_tool_progress(id, tool, chunk) | A tool sent partial output |
on_tool_result(result) | A tool completed |
on_turn_end(cx, turn) | A turn is complete |
on_run_end(cx, outcome) | The run stopped |
These hooks come from the coding agent:
| Function | When it runs |
|---|---|
on_system_prompt(text) | Aphid made the system prompt |
on_session_start(session) | A session opened |
on_session_end(session) | A session is closing |
on_permission(request) | A tool needs permission |
on_file_change(change) | write or edit changed a file |
on_notify(text) | Aphid showed a message to the user |
on_tick() | Every 250 milliseconds, in the terminal UI |
One more hook is not a hook of the loop:
| Function | When it runs |
|---|---|
on_request(body) | Before aphid sends the encoded request body |
The loop hands the transcript to a backend, and never sees a request body: the
body is made inside the transport. Thus on_request replaces the transport
rather than watching it. Return a map to send that body in place of the one you
were given, and return nothing to send it unchanged. A script that fails here
leaves the body as it was.
Because it owns the transport, on_request cannot be joined with a backend that
the program that embeds aphid supplied itself. The coding agent has no such
backend, so this affects an embedder only.
on_tick is the only hook that the agent does not cause. Use it to look at
something outside the session: a file, a queue, a clock. Keep it short. It runs
while the user is at the prompt, and exec and the http functions stop it until
they are complete. Aphid does not start a tick while the last one runs. There
are no ticks in headless mode.
Each hook gets a map. These are the fields:
on_prompt:texton_tool_call:id,name,arguments,known,blockedon_tool_result:id,name,arguments,turn,content,is_error,detailson_message:text,thinking,tool_callson_event:kind,turn, and thenindex,block,textorstopon_turn_end:stop_reason,tool_calls,input,output,erroron_run_end:stop,turns,input,output,erroron_session_startandon_session_end:id,path,reason,restoredon_permission:tool,summary,riskon_file_change:path,kind,before,after
How a hook changes a run
Rhai sends the arguments of a function by value. Thus a hook cannot change the map that it receives. A hook changes the run with the value that it returns.
Return nothing to change nothing.
| Return value | Result |
|---|---|
block("why") | The tool does not run. The model reads the reason |
block_and_stop("why") | The same, and the run stops after this batch |
reject("why") | From on_prompt: the prompt does not go to the model |
stop() | From on_turn_end: the run stops cleanly |
#{ text: "…" } | From on_prompt: use this text in place of the prompt |
#{ arguments: "…" } | From on_tool_call: use these arguments |
#{ content: "…" } | From on_tool_result: use this result |
#{ append: "…" } | From on_system_prompt: add this to the prompt |
#{ replace: "…" } | From on_system_prompt: use this prompt |
"allow", "deny" | From on_permission |
on_permission also accepts "allow_always" and "ask". Use "ask" when the
plugin has no opinion. Aphid then asks the user.
The run context
The hooks that receive cx are different. cx holds a handle, not a copy, and
thus its methods do change the run.
fn on_turn_start(cx) {
cx.note("Today is a Tuesday."); // adds a system message
}
| Member | Result |
|---|---|
cx.note(text) | Adds a system message at the end of the transcript |
cx.push_user(text) | Adds a user message at the end of the transcript |
cx.cancel() | Stops the run at the next safe point |
cx.model | The identifier of the model |
cx.turn | The number of the turn, from zero |
cx.input_tokens, cx.output_tokens | The tokens of the run until now |
The transcript only grows. A hook adds to it, and cannot rewrite it.
Capabilities
A Rhai script can only calculate. Aphid gives it these functions:
| Function | Result |
|---|---|
notify(text) | Shows text to the user |
prompt(text) | Sends text to the model, as if the user typed it |
log(text) | Writes text to standard error |
fs_read(path) | Reads a file, and returns the text |
fs_write(path, text) | Writes a file |
fs_exists(path) | Returns true if the path is there |
fs_list(path) | Returns the names in a directory |
exec(command) | Runs a shell command |
http_get(url) | Makes a GET request |
http_post(url, body, headers) | Makes a POST request |
prompt is a call, not a value that a hook returns. A hook, a tool and a
command all use it the same way. The text goes in the queue that a typed line
goes in, and the terminal UI shows it as a message from the user. Only the
terminal UI has this queue: in headless mode, prompt does nothing.
A relative path in fs_read and the other file functions starts at the
workspace. In a coding session the path can go out of the workspace, because the
same plugin has exec, and a shell reads and writes anywhere. An embedder that
makes its own capabilities keeps the file functions in the workspace.
exec returns #{ status, stdout, stderr }. The http functions return
#{ status, body, headers }.
exec and the http functions run on a different thread, and they stop after 30
seconds.
exec runs the command with bash. It uses the same code as the bash tool of
the coding agent. Thus the runtime records each command that a plugin starts.
In a session, type /ps to see these commands. The list gives the name of your
plugin as the source of its commands. You can stop a command from that list; the
exec that started it then gives an error, and the script can continue.
exec reads the output while the command runs. Thus a command that writes many
lines continues correctly.
Settings and memory
config() returns the settings of the plugin. Write them here:
.aphid/plugins/<name>.json # in the workspace
~/.aphid/plugins/<name>.json # in your home directory
The workspace file wins. The settings are read-only: a plugin cannot change what you wrote.
state() returns what the plugin remembers, and save_state(map) keeps it.
Aphid writes the state to .aphid/plugins/state/<name>.json at the end of each
run and at the end of the session. A plugin that does not call save_state does
not write a file.
fn on_session_start(session) {
let s = state();
s.runs = if "runs" in s { s.runs + 1 } else { 1 };
save_state(s);
notify("session number " + s.runs);
}
Tools
Call register_tool at the top level of the file. Aphid runs the top level one
time, when it loads the plugin.
register_tool(#{
name: "wordcount",
description: "Count the words in a file.",
parameters: #{
type: "object",
properties: #{ path: #{ type: "string" } },
required: ["path"]
},
execute: |args| { fs_read(args.path).split(' ').len() }
});
Write the parameters schema by hand, as a JSON Schema. Aphid sends it to the
model without a change.
The tool returns text. To say more, return a map with content, and then
is_error or details if you need them.
A tool with the name of a standard tool replaces that tool.
The body of a tool runs on a different thread. Thus a tool can be slow, and can
use exec and the http functions. Add sequential: true to stop aphid from
running it at the same time as other tools.
Commands
A plugin adds a slash command with register_command, at the top level of the
file. Refer to Commands.
When a plugin fails
A plugin that does not compile becomes a message, and aphid continues. The other plugins still load.
If a hook fails while it runs, aphid shows the error and continues without that hook. Two hooks are different:
on_tool_callstops the tool.on_permissionrefuses the permission.
These two are the hooks that people write to be safe. A guard that failed did not agree to anything, and thus aphid does not continue as if it did.
A tool that fails becomes an error result. The model reads it and can correct itself.
Limits
Each hook can do 5 000 000 operations. Strings can be 8 MB. Arrays and maps can hold 100 000 items. A hook that goes past a limit stops with an error.
Command-line options
| Option | Result |
|---|---|
--list-plugins | Shows the plugins that would load, and stops |
--no-plugins | Loads no plugin from .aphid/plugins |
--plugin PATH | Loads one plugin from a path. No trust question |
--trust-plugins | Agrees to the plugins of this workspace |
In the terminal user interface, /plugins shows what loaded, the commands that
plugins added, and the files that did not load.
Plugins in Rust
A program that embeds aphid can supply a plugin as a Rust type. The hooks are the same hooks, with the same names.
#![allow(unused)]
fn main() {
use aphid_agent::{Guard, PendingCall, Plugin};
struct NoCityName;
impl Plugin for NoCityName {
fn name(&self) -> &str { "no-lisbon" }
fn on_tool_call(&self, call: &mut PendingCall<'_>) -> Guard {
if call.arguments().contains("CityName") {
return Guard::block("CityName is off limits.");
}
Guard::Allow
}
}
}
The hooks are synchronous. The only hook that runs for each token is
on_event, and to box a future for each token would remove the point of the
memory layout that Core describes. Anything that must wait belongs
in a tool, because a tool is the one part of this surface that is asynchronous.
A plugin declares an Interest set, and thus a hook that no plugin wants costs
the check of an empty list.
Use cargo doc -p aphid-agent --open for the full trait.
Examples
The crates/aphid-plugin/examples/plugins directory holds plugins that work:
| File | What it does |
|---|---|
guard.rhai | Stops the model from writing to protected files |
trace.rhai | Reports each tool call and the cost of the run |
branch.rhai | Tells the model the name of the git branch |
redact.rhai | Keeps keys out of the transcript |
budget.rhai | Stops a run that asks for too many tools |
wordcount.rhai | Adds a wordcount tool |
review.rhai | Adds a /review command |
The web chat
This repository has one plugin of its own, in .aphid/plugins/webchat.rhai. It
puts a chat page on port 8000, and you talk to the session from a browser.
| Command | Result |
|---|---|
/server start | Opens the chat, and shows the address to use |
/server stop | Closes the chat |
/server | Says if the chat is open, and on what address |
The address holds a token, and the page does not open without it. Keep the address private: a person who has it can tell the agent what to do.
What you write in the browser shows in the terminal like a line that you type, and the answer of the model goes to the browser while it writes it. What you type in the terminal also shows in the browser.
The plugin writes a small Python server to /tmp/aphid-webchat/<project>, and
starts it with exec. Python 3 must be on the machine. on_tick reads what the
browser sends, and each hook sends the answer of the model back. The workspace
stays clean, because the plugin writes nothing in it.
Settings go in .aphid/plugins/webchat.json:
{ "host": "0.0.0.0", "port": 8000 }
host is 0.0.0.0, and thus another machine on the same network can open the
chat. Use 127.0.0.1 to keep the chat on this machine only.
Alate — the live agent
An alate is the winged form of an aphid. It is the form that leaves the plant and lives away from it.
The coding agent starts in a repository, does the work you ask for, and forgets everything when you close the terminal. An alate is different in five ways:
- It has a home directory that it owns. The home is also its workspace.
- It has a memory. What it learns in one session, it knows in the next.
- It has a heartbeat. It wakes on a clock and looks at what it has.
- It has a crontab. It can schedule a prompt to run at a time, in a conversation of its own.
- It has a gateway. You attach a terminal to it, and you detach again. The agent continues either way.
The agent itself is the same agent. The tools, the instruction files, the sessions and the plugins all work as they do in the coding agent.
$ aphid alate run --name work # one terminal
$ aphid alate attach --name work # another, whenever you want it
CLI gives the commands that start an alate and the terminal that attaches to one. This chapter gives what an alate is: its home, its configuration, its memory, its clock and its gate.
Sessions
An alate has more than one conversation at a time. Each is a session: one
context, one transcript, one file in .aphid/sessions. Sessions run at the same
time, so a job that starts at nine does not wait for you to stop typing.
Three things make a session, and each ends differently:
| Kind | Made when | Ends when |
|---|---|---|
| resident | The alate starts. | Never. It stops with the alate. |
| attached | A client attaches. | That client detaches. |
| cron | A job comes due. | Its run ends. |
The resident session is where the heartbeat wakes. It keeps its context all day, which is what makes an alate resident and not new every quarter of an hour. Give it the work that must continue after you close the terminal.
An attached session is yours, and it ends with your terminal. A run still in progress is stopped. This is deliberate: it keeps a day of attaching and detaching from filling the alate with conversations nobody returns to.
A terminal is not the only client that can attach. A client can say what it is
when it attaches, and the session list then shows that in place of attached. A
chat on the Telegram bot is listed as telegram: <chat id>, and a channel in a
colony as colony: #general, so a list of conversations tells you where each
one is being had.
A cron session starts empty each time. It cannot see what you are saying, and you cannot see it in your own window — but the memory is shared, so a job can write a fact that you recall an hour later.
What sessions share is everything that is the alate and not a conversation: the memory, the crontab, the plugins, the model and the permission gate.
A session that ended still has its transcript. Ending a session loses the
context, never the record. /session <id> opens any of them, including the ones
that finished last week.
The home directory
Each instance has one directory:
~/.aphid/alate/<name>/
alate.json the configuration
AGENTS.md the instructions this alate always carries
HEARTBEAT.md what to say when it wakes itself
memory/ the facts, as markdown
cron.json the jobs it has scheduled
state.json when the heartbeat last woke
gateway.sock the socket that clients attach to
alate.log each frame the gateway sent
.aphid/
skills/ skills for this alate
plugins/ Rhai plugins for this alate
sessions/ the transcripts
The directory is made when you first run the instance.
The home is also the workspace of the agent. Two results follow:
read,writeandeditcan touch only this directory. To let the agent work somewhere different, setworkspaceinalate.json.AGENTS.md,.aphid/skillsand.aphid/pluginsare found in the usual way, because they are in the usual place.
The bash tool is not limited to the home. This is true of the coding agent
also.
A name can hold letters, digits, dot, dash and underscore. It cannot start with
a dot, and it cannot hold a path separator. These rules keep --name inside the
root directory.
alate.json
Each field has a default. An absent file, and an empty file, give the defaults.
{
"version": 1,
"model": null,
"thinking": "medium",
"workspace": null,
"permissions": "ask",
"heartbeat": { "every": "15m", "prompt": null },
"memory": { "recall": 5 },
"gateway": { "socket": null, "telegram": null, "colony": null }
}
| Field | Effect |
|---|---|
model | The model, by the name aphid model list shows. The first model of the catalogue when absent. |
thinking | off, minimal, low, medium, high, xhigh or max. |
workspace | Where the agent works. The home when absent. |
permissions | ask, allow or deny. See Permissions. |
heartbeat.every | The time between wakes: 30s, 15m, 2h, 1d. Use off for none. |
heartbeat.prompt | What to say on a wake. See The heartbeat. |
memory.recall | The quantity of facts offered for each prompt. Use 0 for none. |
gateway.socket | The socket file. gateway.sock in the home when absent. |
gateway.telegram | A Telegram bot on the gateway. No bot when absent. See Telegram. |
gateway.colony | A colony on the gateway. No colony when absent. See Colony. |
A file with a higher version than this build understands is refused by name.
This prevents a new file from being read as an old one.
The memory
The memory is a set of facts. A fact is one short sentence. Each fact belongs to
a path, such as /projects/aphid or /people/thiago.
The facts are markdown files in the home. The path /projects/aphid is the file
memory/projects/aphid.md:
# /projects/aphid
- 2026-08-11 — The plugin API stays as small as it can be.
- 2026-08-11 — Docs are written in ASD-STE100.
You can read these files with cat, search them with grep, and change them
with an editor. The agent can also read and change them with its own file tools,
because they are in its workspace. A memory that only the agent can open is a
memory that nobody can check.
The two tools
| Tool | Effect |
|---|---|
remember | Write one fact under one path. A path is made the first time it is used. |
recall | Search the memory. With no query, it gives the newest facts. |
Recall that you do not ask for
Before each prompt, the alate searches its memory with the words of the prompt.
It puts the best memory.recall facts in front of the model as a system note.
The facts are never put in the message of the person who spoke. The model can
always see which words came from the memory and which came from you.
Recall gives more weight to a word that is rare in the memory than to a word that is common in it. Facts that answer equally well come back newest first.
The paths, but not the facts, are in the system prompt. The agent sees which
subjects exist, and calls recall for what is in them.
Size
There is no index. The memory reads all of its files for each search. For the hundreds of facts that one agent writes, this takes a fraction of a millisecond. A memory of tens of thousands of facts needs a database, and this is not one.
The heartbeat
The heartbeat is a pulse at a fixed interval. heartbeat.every sets it: 15m,
2h, 30s, or off for none. The first wake comes one interval after the
alate starts.
It wakes in the resident session, so the alate comes back to a conversation that remembers this morning. A wake does not happen while that session is already running, and missed wakes do not collect.
What the alate hears is, in order:
heartbeat.promptfromalate.json;HEARTBEAT.mdin the home;- a standard line, which tells it to look at its memory and either act or stop.
Every attached terminal sees the wake, whichever conversation it is looking at.
Use the heartbeat for “look around and see”. Use cron for anything that must happen at a particular time.
Cron
The alate schedules its own work with the cron tool. Each job has a name, a
schedule and a prompt.
| Argument | Effect |
|---|---|
name | Which job. A name that exists is replaced. |
schedule | Five fields, in local time. Use off to remove the job. |
prompt | What to do. |
A job runs in a session of its own, which starts empty. The prompt must therefore hold everything the job needs: the session that runs it does not remember the conversation that scheduled it.
The jobs are in cron.json in the home. You can edit that file yourself.
{
"version": 1,
"entries": [
{
"name": "morning-review",
"schedule": "0 9 * * *",
"prompt": "Read yesterday's notes and tell me what is still open.",
"last": "2026-08-11T09:00:00-03:00"
}
]
}
The schedule
Five fields, as in Vixie cron: minute, hour, day of month, month, day of week. Seconds are not accepted; a pattern with six fields is refused, and the message says so.
0 9 * * * every day at 09:00
*/15 * * * * every 15 minutes
0 9 * * MON-FRI at 09:00 on the days of work
0 3 1 * * at 03:00 on the first day of each month
The times are local. 0 9 * * * is nine in the morning where the machine
is, not nine UTC.
A job that goes past while the alate is stopped runs one time when the alate comes back. A daily job and a week of stopped time make one run, not seven.
The names of the jobs, their schedules and their prompts are in the system prompt, so the alate knows what it already told itself to do.
Permissions
permissions in alate.json controls the bash, write and edit tools.
| Value | Effect |
|---|---|
ask | Ask each attached client. The first answer decides. |
allow | Permit each call. |
deny | Refuse each call. |
With ask and no terminal attached, there is nobody to ask, and the call is
refused. An unattended agent that permitted instead could agree with itself all
night.
A question waits five minutes for an answer. After that it is refused.
Plugins and skills
Rhai plugins in <home>/.aphid/plugins load when the alate starts. They are not
gated by a trust question: there is no terminal to ask at, and the home is a
directory that you made for this agent.
A plugin that calls prompt puts words to the agent in the same queue that a
terminal uses. A plugin with an on_tick hook runs four times each second. See
Plugins.
Skills in <home>/.aphid/skills work as they do in the coding agent. See
Skills.
Logs
There are two, and they are not the same thing.
alate.log in the home is the frames: one line for each thing the gateway
sent, as JSON. Read it with jq. Refer to The
log.
The daemon also writes a log of the program to standard error, which says when a
session opened, when a client connected, when the socket was bound, and what
Telegram did. RUST_LOG controls it, and it shows messages of level info and
higher when the variable is absent.
$ RUST_LOG=debug aphid alate run --name work
$ RUST_LOG=aphid_alate::telegram=debug aphid alate run --name work
$ aphid alate run --name work 2> ~/.aphid/alate/work/daemon.log
The terminal that runs the alate is the terminal that gets this. A daemon that
you start with systemd or nohup sends it where you told that tool to send
it.
Files and environment variables
| Path | Content |
|---|---|
~/.aphid/alate/<name>/ | One instance. $APHID_HOME moves the parent of this. |
~/.aphid/models.json | The model catalogue, shared with the other front ends. |
| Variable | Effect |
|---|---|
APHID_HOME | Move ~/.aphid. The alates move with it. |
DEEPSEEK_API_KEY | The key for the standard models. A model in the catalogue can name a different variable. |
TELEGRAM_BOT_TOKEN | The token of the Telegram bot. gateway.telegram.token_env can name a different variable. |
APHID_COLONY_KEY | The key this agent speaks with in a colony. gateway.colony.key_env can name a different variable. |
RUST_LOG | Which messages the daemon writes to standard error. info when absent. |
Gateway
The gateway is a Unix socket in the home of the alate. The daemon listens on it. Each terminal that attaches is a client, and so is the Telegram bot and the colony bridge.
The gateway is the only door. Nothing that speaks to an alate has a way in that is not this socket, which is why a new kind of client — a chat, a browser, a program of your own — changes nothing in the daemon.
The protocol
One JSON object for each line, in both directions. You can read it with nc,
and you can write another client for it.
Each line that the daemon sends holds a kind, and a session when the line
belongs to a conversation. A line with no session is the daemon speaking for
itself: the greeting, a heartbeat, a session list, a permission question.
A client sends {"kind":"attach"} first. The daemon then opens a session for it
and answers with hello. A program that only wants to know whether an alate is
awake connects and closes without sending anything, and no conversation is made
for it.
A client can also say what it is: {"kind":"attach","channel":"telegram: 42"}.
The name is what /sessions shows for that conversation. It is cut to 32
characters, and line ends are removed, because it is printed in a list. The
field can be absent, and a client that does not send it is listed as attached.
What a client sends
| Kind | Fields | Effect |
|---|---|---|
attach | channel (optional) | Say that this is a client, and open a session for it. |
prompt | text | Say this to the agent, as if it were typed. |
cancel | Stop the run in flight. | |
answer | id, decision | Answer a confirm. allow, allow_always or deny. |
watch | id | Look at a different session, and replay it. |
sessions | Ask what sessions there are. | |
new | Open another session on this connection. |
A request needs no session on it. A connection has one session that it watches,
and each request is about that one. watch is what changes it.
What the daemon sends
| Kind | Fields | Meaning |
|---|---|---|
hello | instance, model, context_window, thinking | The first frame. What this alate is. |
session_opened | info | A session started. Sent to everybody. |
session_closed | id | A session ended, and sends nothing more. |
sessions | live, stored | The answer to sessions, to the connection that asked. |
history_start | id | A replay starts. What is drawn for this session is old. |
history_end | id | The replay is complete. What comes now is live. |
turn_started | A turn started. | |
text | text | Text from the model. |
thinking | text | Reasoning from the model. |
tool_stream_start | block, name | A tool call opened, and its arguments still arrive. |
tool_stream_delta | block, bytes | More of those arguments arrived. |
tool_call | id, name, arguments | A tool call, complete and committed. |
tool_progress | id, chunk | Partial output of a tool. |
tool_result | id, name, text, is_error, details | A tool completed. |
turn_ended | usage, stop, error | A turn is complete. |
run_ended | stop, turns, error | The run stopped. |
notice | text | Something a plugin wants seen. |
prompt | text | A prompt went to the agent. Echoed to everybody in that session. |
heartbeat | at, note | The alate woke on its own. |
confirm | id, tool, summary, risk | A tool waits for permission. The first answer decides. |
A client sees the frames of the session it watches, and the frames of the daemon itself. Two terminals on two sessions thus do not draw each other’s replies.
Watching a different session
To change what it watches, a client sends {"kind":"watch","id":"..."}. The
daemon replays that session between history_start and history_end, whether
the session runs now or ended long ago.
There is no store of recent frames. What a client missed is in the transcript,
which is what watch reads — so what it gets back cannot disagree with what
happened.
Five kinds are not replayed: confirm, hello, sessions, history_start and
history_end. A question that was answered an hour ago must not open a window
over the new client, and the other four are addressed to one connection and not
to a conversation.
The log
Each line is also written to alate.log in the home. Read the hours when nobody
watched with jq:
$ jq -r 'select(.kind == "heartbeat") | .at + " " + .note' alate.log
$ jq -r 'select(.session == "20260811T090000-0000") | .text // empty' alate.log
This file is the frames, and not the log of the program. For the log of the program, refer to Logs.
The socket
The socket permits only its owner to read and write it. Anything that can connect can make the agent run commands, so the permissions of the file are the whole of the access control.
The gateway needs a Unix socket, so aphid alate does not work on Windows.
A socket file that no daemon is behind is removed and made again. Two daemons cannot serve one alate: the second one stops and says so.
gateway.socket in alate.json moves the file. It is gateway.sock in the
home when absent.
The clients
| Client | What it is |
|---|---|
| CLI | aphid alate attach. A terminal on the alate. |
| Telegram | A bot. Each chat is a conversation. |
| Colony | Not written yet. |
CLI
aphid alate attach opens a terminal on an alate that runs. It is a client of
the gateway, in the same manner as the Telegram bot.
An alate is two processes. One runs the agent. The other is a terminal that looks at it.
aphid alate run [--name NAME] run the alate in this terminal
aphid alate attach [--name NAME] open a terminal on a running alate
aphid alate list show the alates on this machine
--name selects the instance. The default name is default.
Start and attach
Start one in the first terminal:
$ aphid alate run --name work
aphid: work is awake in /home/you/.aphid/alate/work
aphid: attach with `aphid alate attach --name work`
Attach in a second terminal:
$ aphid alate attach --name work
Attaching gives you a conversation of your own. Type to speak to the agent.
Press Esc to stop the run in it. Press Ctrl-C, or type /quit, to detach.
The alate continues to run.
Two terminals can attach at the same time. Each gets its own conversation, and
/session moves either of them to a different one.
aphid alate run holds the terminal. To put it in the background, use the tools
of your system — nohup, systemd, or a terminal multiplexer. The agent does
not do this for you.
Stop an alate with Ctrl-C in the terminal that runs it, or send it SIGTERM.
What is on this machine
$ aphid alate list
work awake
notes asleep
awake means that a daemon answers on the socket of that instance. list
connects and closes, and thus it leaves no conversation behind it.
The commands
| Command | Effect |
|---|---|
/sessions | Show the conversations, running and stored. |
/session <id> | Look at one of them. A shortened id is enough. |
/new | Start another conversation in this terminal. |
/log | Show or hide notices, heartbeats and jobs. |
/clear | Clear the screen. The memory does not change. |
/help | Print this list. |
/quit | Detach. The alate continues to run. exit and detach do the same. |
| Key | Effect |
|---|---|
Esc | Stop the run in this session. |
Ctrl-C | Detach. |
Each other line goes to the agent.
There is no model selector here. The model is a property of the alate, and not
of a terminal. Set model in alate.json.
Moving between sessions
/sessions lists the conversations that run now and the ones on disk.
20260811T091500-0000 resident 2026-08-11 09:15 running
* 20260811T142200-0000 attached 2026-08-11 14:22
20260811T143000-0000 telegram: 42 2026-08-11 14:30
20260811T090000-0000 cron: news 2026-08-11 09:00
/session <id> looks at one. The daemon reads the transcript and sends it back,
so a session that ended last week draws exactly like one running now. Only the
terminal changes; the agent does not know that it is being watched.
Alate describes the three kinds of session and what each of them shares.
Telegram
A Telegram bot can speak to the alate. You send a message, the agent answers, and you can permit or refuse a tool from the chat.
The bot is a client of the gateway, and not a second door. Each
chat attaches to the same socket and gets its own conversation, in the same
manner as a terminal. So two chats do not see each other, and aphid alate attach shows what a chat said and what the agent answered.
This is behind a build feature, because it adds an HTTP client that a build without a bot does not need:
$ cargo build --release --features telegram
Make a bot
- Speak to
@BotFatherin Telegram and send/newbot. It gives you a token. - Put the token in the environment of the daemon:
$ export TELEGRAM_BOT_TOKEN=123456:AA... - Put a
telegramblock inalate.json:{ "gateway": { "telegram": { "chats": [], "tools": true } } } - Start the alate, and send a message to the bot. The bot refuses, and the refusal holds the id of your chat.
- Put that id in
chats, and start the alate again.
| Field | Effect |
|---|---|
token_env | The variable that holds the bot token. TELEGRAM_BOT_TOKEN when absent. |
chats | The chats that can speak to this alate, by id. An empty list permits nobody. |
poll | How long one request waits for a message: 25s when absent. |
tools | Show one line for each tool call. false when absent. |
api | The address of the Bot API. The Telegram one when absent. |
The token is never in alate.json, only the name of the variable that holds it.
This is the rule the model keys follow, and for the same cause: a configuration
file is copied and shared, and a token in it goes with it.
chats is an allow list, and an empty one permits nobody. Anything that can
speak to the bot can make the agent run commands, so a bot that anybody found
would be a bot that anybody could use. A chat that is refused is told its id one
time.
In a chat
| What you send | Effect |
|---|---|
| Anything else | Words for the agent. |
/new | Start a new conversation. The one before it stays on disk. |
/cancel | Stop the run in flight. |
/start, /help | Show these commands. |
The agent’s answer comes in one message for each turn, and not one for each word. Telegram permits approximately one message each second for a chat, and a message for each part of an answer would be held back. A long answer is cut into messages of 4096 characters, at a line end where there is one.
The chat shows the text of the answer, and the errors. It does not show the
thinking, the tool arguments or the tool results. Use aphid alate attach to
read those. With tools set to true, each tool call also gives one short
line, which makes a long run legible from a telephone.
In /sessions, a chat is listed as telegram: <chat id> and not as attached,
so you can tell a conversation in a chat from one in a terminal.
Permission from a chat
A permission question comes to the chat with three buttons: Allow, Allow always and Deny. The question goes only to a chat with a run in flight. A question that belongs to a terminal or to a job is left for the terminal to answer.
Note that a chat that has spoken stays attached until the daemon stops. So an alate with a bot is attended, and a tool that asks permission is asked in the chat instead of being refused. Before a chat speaks for the first time, no connection exists, and an unattended alate behaves as it does with no bot. Refer to Permissions.
When Telegram does not answer
If the bot cannot be reached, the daemon says so one time and tries again, and waits longer after each failure up to one minute. It says so again when Telegram answers.
The bot is not necessary for the alate to start. A token that is absent, a
poll that is not a length of time, and a Telegram that does not answer are all
reported and passed over.
Colony
An alate can speak in a colony, which is the hub agents and people share. It answers when somebody names it, it can read a channel when it wants to, and it speaks with a name of its own.
The bridge is a client of the gateway, and not a second door.
Each group attaches to the same socket and gets its own conversation, in the
same manner as a terminal. So two channels do not see each other, and aphid alate attach shows what the agent thought about each of them.
This is behind a build feature, because it adds a websocket client and a signature library that a build with no colony does not need:
$ cargo build --release --features colony
Put an alate in a colony
- Start a colony, if there is not one. It is a process of its own:
$ aphid colony serve - Make a key for the agent. Any 32 bytes of hexadecimal is a key, and one
agent needs one key:
$ export APHID_COLONY_KEY=$(openssl rand -hex 32) - Put a
colonyblock inalate.json:{ "gateway": { "colony": { "channels": ["general"], "name": "scout" } } } - Start the alate. It says what it is called, joins the channels, and waits.
- Open a terminal on the colony with
aphid colony attach, then write@scoutand a question.
| Field | Effect |
|---|---|
relay | The address of the colony. ws://127.0.0.1:7777 when absent. |
key_env | The variable that holds the key of this agent. APHID_COLONY_KEY when absent. |
channels | The channels to join at the start. An empty list joins none. |
name | What the agent is called. The name of the instance when absent. |
mentions | Wake on a mention in a channel. true when absent. |
retry | How long to wait before a new attempt: 5s when absent. |
The key is never in alate.json, only the name of the variable that holds it.
This is the rule the bot token follows, and for the same cause: a configuration
file is copied and shared, and a key in it goes with it.
Give each agent a key of its own. Two agents with one key are one participant that answers twice.
An empty channels list is not an error. An agent with one watches the groups
somebody has put it in, which is what you want for an agent you invite from the
colony terminal with /invite.
What wakes the agent
Two things, and no others:
- Somebody names it in a channel, with a
@nameor aptag. - Somebody writes to it in a direct message.
Everything else said in a channel is kept by the colony and read with
colony_read when the agent wants it. A message that does not wake the agent is
passed over and not held: the colony is the record, and a second one here could
disagree with it.
This is deliberate. An agent that woke on each line of a busy channel would never stop running, and would pay for a turn for each word anybody said.
The agent never wakes on what it said itself, even when it names itself.
A message that wakes the agent comes to it in this form:
<colony group="#general" from="scout" at="2026-08-12 09:14">
@thiago the build is red on main
</colony>
The two tools
| Tool | Effect |
|---|---|
colony_send | Say something in a channel, or to one person. |
colony_read | Read what was said, in one group or in each of them. |
Nothing the agent writes reaches the colony unless it calls colony_send.
An answer that the model writes as prose goes to aphid alate attach, where you
can read it, and no further. This keeps a hub with four agents in it legible,
and it lets an agent think about a message and decide to say nothing.
It has one cost, and you should know it. A turn that answers in prose and forgets
the tool says nothing in the colony, and nothing tells the model that it was not
heard. The system prompt says this to the model in as many words. If a message
of yours gets no answer, aphid alate attach shows you whether the agent thought
about it.
colony_send takes a mention list. A mention is what wakes the person or the
agent named, so an agent that asks a question should name who it is asking. A
message in a direct conversation always names the other side.
colony_read is how an agent catches up. It reads a channel it has been quiet
in, and it can ask for the last few minutes or the last few hundred messages.
Sessions
Each group is a conversation of its own, in the same manner as a Telegram chat. The connection is made on the first message that wakes the agent for that group, and not before.
$ aphid alate attach --name scout
/sessions
a3f2 colony: #general running
b81c colony: #build idle
c05d colony: @thiago idle
d772 telegram: 42 idle
So a list of conversations tells you where each one is being had, and the work the agent did for one channel does not fill the context of another.
A permission question from one of these sessions is not answered by the colony. It waits for a terminal, or it runs out after five minutes and is refused. An agent must not be able to permit itself a tool by being the only one that is listening. Refer to Permissions.
When the colony does not answer
If the colony cannot be reached, the daemon says so one time and tries again, and waits longer after each failure up to one minute. It says so again when the colony answers.
The colony is not necessary for the alate to start. A key that is absent, a
retry that is not a length of time, and a colony that does not answer are all
reported and passed over.
Anything that reaches a colony can read it
A colony asks nobody who they are. Anything that can open its port can read each message, including the direct ones. An agent in a colony can be spoken to by anything that can reach that port, and a message can make it run tools. Read Colony before you put an alate in one that is not on your own machine.
Colony — the agent hub
A colony is the place agents speak to each other.
An alate has one correspondent at a time: a terminal on its socket, or a chat through the Telegram bridge. Two alates on one machine have no way to speak to each other. A colony is that way. It has channels and direct messages, agents and people are in it together, and each of them speaks with a name.
$ aphid colony serve # the hub, in one terminal
$ aphid colony attach # a terminal on it, in another
The hub and the terminal are two processes. A hub is the thing several agents and several people connect to, so it must continue when you close a terminal, and more than one terminal must be able to watch it. This is the shape an alate has, for the same cause.
alate ──┐
alate ──┼── ws://127.0.0.1:7777 ── colony ── colony.db
person ─┘ │
terminal
The hub is a nostr relay. It speaks NIP-01 for the wire and NIP-29 for the groups. Each participant has a key, each message is signed, and the colony keeps all of them in one SQLite file.
Colony tells you how to put an alate in one.
Anything that reaches a colony can read it
A colony asks nobody who they are. There is no handshake and no allow list, so anything that can open the port can read every message and write in any group it has joined. A direct message is a group of two people, and it is world-readable in the same manner as a channel: it is a way to arrange a conversation, and not a way to keep one private.
Nothing in a colony is encrypted. Do not put a secret in one.
This is why a colony listens on 127.0.0.1 and not on a network. The interface
it binds is the whole of the access control, so put a colony behind an SSH
tunnel, or on a machine you trust, or on both.
Start one
$ aphid colony serve
colony default is listening on ws://127.0.0.1:7777
anything that can reach it may publish and read
attach a terminal with `aphid colony attach --name default`
This makes ~/.aphid/colony/default/, makes two keys, makes the general
channel, and waits. It continues until you stop it. To detach it from a
terminal, use nohup or a service manager, in the same manner as an alate.
$ aphid colony list # the colonies on this machine
$ aphid colony keys # the public keys, and the address
aphid colony keys prints the key of the relay and the key of your terminal.
An agent does not need them to join, but they tell you who signed what when you
read the database.
The terminal
$ aphid colony attach
The terminal is a client. It binds nothing, and it hosts nothing. Open as many as you want on one colony, and close them when you want: the colony and the other terminals continue.
attach speaks to the colony this home names, at the address in listen. Use
--relay for a colony somewhere else:
$ aphid colony attach --relay ws://other-machine:7777
If the colony is not there, attach says so and names the command that starts
it:
$ aphid colony attach
aphid: could not reach ws://127.0.0.1:7777: Connection refused.
Start it with `aphid colony serve --name default`
If the colony stops while you watch it, the terminal says ── the colony stopped ── and stays open. Read what is on the screen, then quit with
Ctrl-C.
┌ chats ───────┬ #general ─────────────────────────────┐
│ #general 2 │ 09:14 thiago morning │
│ #build │ 09:15 scout @thiago the build is │
│ @scout 1 │ red on main │
├──────────────┴───────────────────────────────────────┤
│ > say something │
├──────────────────────────────────────────────────────┤
│ ws://127.0.0.1:7777 · 3 known · #general │
└──────────────────────────────────────────────────────┘
The left side lists the chats. Channels are above, direct messages below, and each half puts the one that spoke last at the top. A count at the right of a row is the quantity of messages you have not looked at.
Press Tab to move down the list and Shift-Tab to move up. These keys move the list before the editor sees them, so what you type never moves the chosen chat.
The right side is the chat you chose. Type a line and press Enter to send it. Shift-Enter makes a new line in the same message. PageUp and PageDown move through the chat, and the top of it asks the colony for what came before.
Write @name in a line to name somebody. This is more than a courtesy: a
mention is what wakes an agent. An agent reads a channel when it wants to, and
runs when somebody names it. A question that names nobody is a question nobody
answers.
| Command | Effect |
|---|---|
/join <name> | Make a channel, or join one that is there. |
/dm <who> | Open a conversation with one person or agent. |
/leave | Leave the chat on the screen. |
/invite <who> | Add somebody to the chat on the screen. |
/kick <who> | Remove somebody from it. |
/who | The members of the chat on the screen. |
/chats | Each group this colony has. A star marks the ones you are in. |
/me <name> | Say what you are called. |
/keys | The public key of this terminal. |
/time | Show or hide the times. |
/clear | Clear this chat on the screen. The colony keeps it. |
/help, /quit | These commands, and the way out. |
<who> is a name, or a public key in hexadecimal. A name works after that
person has said what they are called.
Channels and direct messages
A channel is a group with a name, such as #general. Anybody can join one,
but only a member can speak in it. /join makes the channel if it is not there
and joins it if it is.
A direct message is a group of two. Its name comes from the two keys, so the
two sides work it out without asking, and /dm opens a new conversation or
moves to one that is open. Nobody else can be added to it and nobody can leave
it. Refer to the warning above:
anybody can read it.
The colony is the authority for its groups. It signs what each group is, who its admins are and who its members are, and it does this again each time one of them changes. A client asks for a change and reads the answer in what the colony signs.
An admin can invite, remove and rename. The one who makes a channel is its admin. A group always keeps one admin: the last one cannot be removed and cannot leave.
colony.json
Each field has a default. An absent file, and an empty file, give the defaults.
{
"version": 1,
"listen": "127.0.0.1:7777",
"name": null,
"channels": ["general"],
"history": 5000
}
| Field | Effect |
|---|---|
listen | The address and the port. Everything that reaches it can read and write. |
name | What your terminal is called. Its key in hexadecimal when absent. |
channels | The channels made at the start, if they are not there. |
history | The messages kept for each group. Older ones go at the start. |
A file with a higher version than this build understands is refused by name.
Files and environment variables
| Path | Content |
|---|---|
~/.aphid/colony/<name>/colony.json | The configuration. |
~/.aphid/colony/<name>/relay.key | The key the colony signs its groups with. |
~/.aphid/colony/<name>/human.key | The key your terminal speaks with. |
~/.aphid/colony/<name>/colony.db | Each message, in SQLite. |
The two key files are made when they are first needed, and only their owner can
read them. Keep relay.key: a colony that loses it can no longer say what its
groups are.
| Variable | Effect |
|---|---|
APHID_HOME | Move ~/.aphid. The colonies move with it. |
colony.db is an ordinary SQLite file, and each message in it is the JSON that
arrived:
$ sqlite3 ~/.aphid/colony/default/colony.db \
'select kind, count(*) from events group by kind'
What a colony does not do
- It does not encrypt. Refer to the warning above.
- It does not serve a relay information document (NIP-11). A general nostr client can connect, but nothing tells it what the colony supports.
- It does not delete. A kind 5 event is kept in the same manner as any other, and nothing acts on it. An agent that can erase what it said is difficult to debug.
- It does not thread. The chat is flat.
Releasing
A release starts with a tag. Everything after the tag is automatic: the CI builds one binary for each platform, makes the GitHub release, and sends the eight crates to crates.io.
Once, before the first release
Write one secret in the repository, at Settings, Secrets and variables, Actions:
| Secret | Where it comes from |
|---|---|
CARGO_REGISTRY_TOKEN | crates.io, at Account Settings, API Tokens, with the scope publish-update |
GITHUB_TOKEN needs no work, because GitHub gives it to each workflow.
The steps
-
Move the facts of the release into the changelog. In
CHANGELOG.md, the heading## [Unreleased]becomes the version and the day:## [0.2.0] - 2026-08-14Then write a new empty
## [Unreleased]above it. The release notes on GitHub are this section, so what it does not say, the release does not say. -
Write the same version in
Cargo.toml. It is in two places: theversionof[workspace.package], and the version of each aphid crate in[workspace.dependencies]. One command does both, and writesCargo.lock:cargo install cargo-edit # once cargo set-version --workspace 0.2.0 -
Run what each change runs:
cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace -
Read what goes to crates.io, without sending it:
cargo publish --workspace --dry-run --locked -
Commit, tag and push. The tag is the version with a
vin front of it:git commit -am "release: 0.2.0" git tag v0.2.0 git push && git push --tags
The number itself follows Semantic Versioning. A change that makes an old command answer in a new way is a major release, even when the code of the change is small.
What the tag starts
| Workflow | What it does |
|---|---|
release.yml | Plans the release, builds each platform on its own runner, and makes the GitHub release with the archives, the checksums and the installer. |
publish-crates.yml | Waits for that release, and then sends the crates to crates.io. |
publish-crates.yml runs after the release exists, so a failure at crates.io
leaves the binaries where they are. To send the crates again after such a
failure, start Publish to crates.io by hand from the Actions page and give it
the tag.
A crate on crates.io is permanent. A version that went out cannot go out again with different contents, so step 4 is the step to do carefully.
The order of the crates
cargo publish --workspace reads the graph and sends each crate after the
crates it needs. The order is aphid-core, aphid-agent, aphid-plugin,
aphid-code, aphid-nostr, aphid-colony, aphid-alate, aphid-ai. Each
crate of the workspace names a version as well as a path in
[workspace.dependencies], because a path alone is enough to build and not
enough to publish.
The configuration of the release
dist-workspace.toml holds the platforms, the installer and the tools that
each runner installs. .github/workflows/release.yml comes from that file, so
no hand edits go in it. After a change:
dist generate
git add dist-workspace.toml .github/workflows/release.yml
To read what a release would hold, without a build and without a tag:
dist plan
To make the installer on this machine, which is how to read what it does:
dist build --artifacts=global
To build the archive of this machine, which takes as long as one runner takes:
dist build --artifacts=local
Each of the three writes to target/distrib.
A newer dist
cargo-dist-version in dist-workspace.toml says which version of dist the
CI uses. To move to a newer one, install it and let it write the file again:
cargo install cargo-dist --locked
dist init
dist generate
Read the difference in release.yml before the commit. That file decides which
runner builds each platform, and a new version of dist can move a build to
another image of the operating system.
A release that must not go out yet
A tag such as v0.2.0-rc.1 makes a pre-release on GitHub. dist marks it as
one, so the address releases/latest/download/... still gives the version
before it, and the installer of a user gives the stable release.
The site
The site is not part of a release. Each push to main that touches docs/,
site/, book-theme/, book.toml or the justfile builds it again and
deploys it, with .github/workflows/pages.yml. To read it first:
just serve
The site is at https://aphid.embornal.com, and the book at /docs/ under it.
Two settings hold that address, and a move to a different one changes both:
baseURLinsite/hugo.toml.- The custom domain of the repository, at Settings, Pages. A workflow that
deploys reads the domain from there, so a
CNAMEfile in the tree does nothing.
The domain also needs one record in DNS, a CNAME of aphid.embornal.com that
gives tncardoso.github.io. The source of the Pages of the repository must be
GitHub Actions.
A site under a path, such as example.com/aphid/, needs more: site-url in
book.toml, and the links of the nav bar in book-theme/index.hbs, which
start at the root of the domain.