Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.md instructions and its skills before the agent starts.
  • Fully debuggable. aphid raw prints each protocol event as it occurs, and aphid raw --request prints 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:

CrateWhat it holds
aphid-coreThe message, model and streaming types. See Core.
aphid-agentThe agent loop, the tool registry and the plugin API.
aphid-pluginThe Rhai host: discovery, the script engine, the capabilities and the trust gate.
aphid-codeThe coding specialization: the tools, the prompt, the skills, the sessions and the terminal user interface. See Aphid.
aphid-alateThe resident agent: a home, a memory, a heartbeat and a gateway. See Alate.
aphid-nostrNIP-01 and NIP-29, with no socket and no clock in it. See Colony.
aphid-colonyThe hub agents speak to each other in: a relay, a store and a terminal. See Colony.
aphid-cliThe 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 rustc 1.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 alate does 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

PathContent
~/.aphid/models.jsonYour models.
~/.aphid/AGENTS.mdInstructions 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.mdInstructions 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.

ProfileUse
compatibleA different company’s OpenAI-compatible server. The default.
openaiOpenAI and Azure.
deepseekDeepSeek.
noneNo 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.

ToolEffect
bashRuns a command. Not limited to the workspace.
readReads a file, or a part of one.
writeWrites a full file.
editReplaces 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

OptionEffect
-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.
--modelsPrint 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.
--sessionsPrint the saved sessions for this workspace, and exit.
--confirmAsk before each command that changes the workspace.
--no-contextDo not read AGENTS.md files or skills.
--list-pluginsPrint the plugins that would load, and exit.
--no-pluginsDo not load any plugin from .aphid/plugins.
--plugin <PATH>Load one plugin from a path.
--trust-pluginsAgree to the plugins of this workspace.
--max-turns <N>Stop the run after this quantity of requests.
--quietDo 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
OptionEffect
-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.

OptionEffect
--proUse 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.
--toolSupply a demo get_weather tool, to show tool-call deltas.
--eventsPrint each delta event with its span, in place of the text.
--requestPrint 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
OptionEffect
--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.
--forceReplace a model that is already in the catalogue.
--refreshGet the models.dev document again, even if the copy is new.
--offlineUse 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.

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)
OptionEffect
--limit <N>Print at most this many results. The default is to print them all.
--refreshGet the models.dev document again.
--offlineUse 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

PathContent
~/.aphid/models.jsonYour models.
~/.aphid/models.dev.jsonThe local copy of the models.dev document.
~/.aphid/AGENTS.mdInstructions for each workspace.
<workspace>/AGENTS.mdInstructions 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.jsonThe workspaces whose plugins you agreed to.
~/.aphid/alate/<name>/One resident agent. See Alate.
VariableEffect
APHID_HOMEReplaces ~/.aphid. Use it to keep a separate configuration.
DEEPSEEK_API_KEYThe 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

CodeMeaning
0Success.
1The run failed, or aphid could not read or write a file or the network.
2The 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

CommandEffect
/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, /newStart a new conversation. The system prompt stays.
/toolsList the tools that are registered.
/psShow what the runtime runs now, and what it ran before.
/sessionShow where this session is written.
/pluginsList the plugins that loaded, and the commands they added.
/skillsList the skills that the model can open.
/helpPrint the list.
/quitExit. /q and /exit do the same.
KeyEffect
EscStop the run.
Ctrl-CQuit.
Ctrl-PChange to the next model.
Ctrl-TShow the reasoning.
PageUp, PageDownScroll.

/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`.
...
FieldEffect
descriptionNecessary. What the skill is for, and when to use it.
nameThe 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:

FunctionWhen 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:

FunctionWhen 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:

FunctionWhen 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: text
  • on_tool_call: id, name, arguments, known, blocked
  • on_tool_result: id, name, arguments, turn, content, is_error, details
  • on_message: text, thinking, tool_calls
  • on_event: kind, turn, and then index, block, text or stop
  • on_turn_end: stop_reason, tool_calls, input, output, error
  • on_run_end: stop, turns, input, output, error
  • on_session_start and on_session_end: id, path, reason, restored
  • on_permission: tool, summary, risk
  • on_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 valueResult
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
}
MemberResult
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.modelThe identifier of the model
cx.turnThe number of the turn, from zero
cx.input_tokens, cx.output_tokensThe 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:

FunctionResult
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_call stops the tool.
  • on_permission refuses 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

OptionResult
--list-pluginsShows the plugins that would load, and stops
--no-pluginsLoads no plugin from .aphid/plugins
--plugin PATHLoads one plugin from a path. No trust question
--trust-pluginsAgrees 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:

FileWhat it does
guard.rhaiStops the model from writing to protected files
trace.rhaiReports each tool call and the cost of the run
branch.rhaiTells the model the name of the git branch
redact.rhaiKeeps keys out of the transcript
budget.rhaiStops a run that asks for too many tools
wordcount.rhaiAdds a wordcount tool
review.rhaiAdds 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.

CommandResult
/server startOpens the chat, and shows the address to use
/server stopCloses the chat
/serverSays 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:

KindMade whenEnds when
residentThe alate starts.Never. It stops with the alate.
attachedA client attaches.That client detaches.
cronA 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, write and edit can touch only this directory. To let the agent work somewhere different, set workspace in alate.json.
  • AGENTS.md, .aphid/skills and .aphid/plugins are 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 }
}
FieldEffect
modelThe model, by the name aphid model list shows. The first model of the catalogue when absent.
thinkingoff, minimal, low, medium, high, xhigh or max.
workspaceWhere the agent works. The home when absent.
permissionsask, allow or deny. See Permissions.
heartbeat.everyThe time between wakes: 30s, 15m, 2h, 1d. Use off for none.
heartbeat.promptWhat to say on a wake. See The heartbeat.
memory.recallThe quantity of facts offered for each prompt. Use 0 for none.
gateway.socketThe socket file. gateway.sock in the home when absent.
gateway.telegramA Telegram bot on the gateway. No bot when absent. See Telegram.
gateway.colonyA 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

ToolEffect
rememberWrite one fact under one path. A path is made the first time it is used.
recallSearch 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:

  1. heartbeat.prompt from alate.json;
  2. HEARTBEAT.md in the home;
  3. 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.

ArgumentEffect
nameWhich job. A name that exists is replaced.
scheduleFive fields, in local time. Use off to remove the job.
promptWhat 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.

ValueEffect
askAsk each attached client. The first answer decides.
allowPermit each call.
denyRefuse 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

PathContent
~/.aphid/alate/<name>/One instance. $APHID_HOME moves the parent of this.
~/.aphid/models.jsonThe model catalogue, shared with the other front ends.
VariableEffect
APHID_HOMEMove ~/.aphid. The alates move with it.
DEEPSEEK_API_KEYThe key for the standard models. A model in the catalogue can name a different variable.
TELEGRAM_BOT_TOKENThe token of the Telegram bot. gateway.telegram.token_env can name a different variable.
APHID_COLONY_KEYThe key this agent speaks with in a colony. gateway.colony.key_env can name a different variable.
RUST_LOGWhich 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

KindFieldsEffect
attachchannel (optional)Say that this is a client, and open a session for it.
prompttextSay this to the agent, as if it were typed.
cancelStop the run in flight.
answerid, decisionAnswer a confirm. allow, allow_always or deny.
watchidLook at a different session, and replay it.
sessionsAsk what sessions there are.
newOpen 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

KindFieldsMeaning
helloinstance, model, context_window, thinkingThe first frame. What this alate is.
session_openedinfoA session started. Sent to everybody.
session_closedidA session ended, and sends nothing more.
sessionslive, storedThe answer to sessions, to the connection that asked.
history_startidA replay starts. What is drawn for this session is old.
history_endidThe replay is complete. What comes now is live.
turn_startedA turn started.
texttextText from the model.
thinkingtextReasoning from the model.
tool_stream_startblock, nameA tool call opened, and its arguments still arrive.
tool_stream_deltablock, bytesMore of those arguments arrived.
tool_callid, name, argumentsA tool call, complete and committed.
tool_progressid, chunkPartial output of a tool.
tool_resultid, name, text, is_error, detailsA tool completed.
turn_endedusage, stop, errorA turn is complete.
run_endedstop, turns, errorThe run stopped.
noticetextSomething a plugin wants seen.
prompttextA prompt went to the agent. Echoed to everybody in that session.
heartbeatat, noteThe alate woke on its own.
confirmid, tool, summary, riskA 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

ClientWhat it is
CLIaphid alate attach. A terminal on the alate.
TelegramA bot. Each chat is a conversation.
ColonyNot 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

CommandEffect
/sessionsShow the conversations, running and stored.
/session <id>Look at one of them. A shortened id is enough.
/newStart another conversation in this terminal.
/logShow or hide notices, heartbeats and jobs.
/clearClear the screen. The memory does not change.
/helpPrint this list.
/quitDetach. The alate continues to run. exit and detach do the same.
KeyEffect
EscStop the run in this session.
Ctrl-CDetach.

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

  1. Speak to @BotFather in Telegram and send /newbot. It gives you a token.
  2. Put the token in the environment of the daemon:
    $ export TELEGRAM_BOT_TOKEN=123456:AA...
    
  3. Put a telegram block in alate.json:
    { "gateway": { "telegram": { "chats": [], "tools": true } } }
    
  4. Start the alate, and send a message to the bot. The bot refuses, and the refusal holds the id of your chat.
  5. Put that id in chats, and start the alate again.
FieldEffect
token_envThe variable that holds the bot token. TELEGRAM_BOT_TOKEN when absent.
chatsThe chats that can speak to this alate, by id. An empty list permits nobody.
pollHow long one request waits for a message: 25s when absent.
toolsShow one line for each tool call. false when absent.
apiThe 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 sendEffect
Anything elseWords for the agent.
/newStart a new conversation. The one before it stays on disk.
/cancelStop the run in flight.
/start, /helpShow 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

  1. Start a colony, if there is not one. It is a process of its own:
    $ aphid colony serve
    
  2. 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)
    
  3. Put a colony block in alate.json:
    { "gateway": { "colony": { "channels": ["general"], "name": "scout" } } }
    
  4. Start the alate. It says what it is called, joins the channels, and waits.
  5. Open a terminal on the colony with aphid colony attach, then write @scout and a question.
FieldEffect
relayThe address of the colony. ws://127.0.0.1:7777 when absent.
key_envThe variable that holds the key of this agent. APHID_COLONY_KEY when absent.
channelsThe channels to join at the start. An empty list joins none.
nameWhat the agent is called. The name of the instance when absent.
mentionsWake on a mention in a channel. true when absent.
retryHow 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 @name or a p tag.
  • 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

ToolEffect
colony_sendSay something in a channel, or to one person.
colony_readRead 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.

CommandEffect
/join <name>Make a channel, or join one that is there.
/dm <who>Open a conversation with one person or agent.
/leaveLeave the chat on the screen.
/invite <who>Add somebody to the chat on the screen.
/kick <who>Remove somebody from it.
/whoThe members of the chat on the screen.
/chatsEach group this colony has. A star marks the ones you are in.
/me <name>Say what you are called.
/keysThe public key of this terminal.
/timeShow or hide the times.
/clearClear this chat on the screen. The colony keeps it.
/help, /quitThese 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
}
FieldEffect
listenThe address and the port. Everything that reaches it can read and write.
nameWhat your terminal is called. Its key in hexadecimal when absent.
channelsThe channels made at the start, if they are not there.
historyThe 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

PathContent
~/.aphid/colony/<name>/colony.jsonThe configuration.
~/.aphid/colony/<name>/relay.keyThe key the colony signs its groups with.
~/.aphid/colony/<name>/human.keyThe key your terminal speaks with.
~/.aphid/colony/<name>/colony.dbEach 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.

VariableEffect
APHID_HOMEMove ~/.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:

SecretWhere it comes from
CARGO_REGISTRY_TOKENcrates.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

  1. 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-14
    

    Then 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.

  2. Write the same version in Cargo.toml. It is in two places: the version of [workspace.package], and the version of each aphid crate in [workspace.dependencies]. One command does both, and writes Cargo.lock:

    cargo install cargo-edit    # once
    cargo set-version --workspace 0.2.0
    
  3. Run what each change runs:

    cargo fmt --all --check
    cargo clippy --workspace --all-targets -- -D warnings
    cargo test --workspace
    
  4. Read what goes to crates.io, without sending it:

    cargo publish --workspace --dry-run --locked
    
  5. Commit, tag and push. The tag is the version with a v in 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

WorkflowWhat it does
release.ymlPlans the release, builds each platform on its own runner, and makes the GitHub release with the archives, the checksums and the installer.
publish-crates.ymlWaits 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:

  • baseURL in site/hugo.toml.
  • The custom domain of the repository, at Settings, Pages. A workflow that deploys reads the domain from there, so a CNAME file 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.