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
$ aphid alate gui --name work # or a window, on the desktop
CLI gives the commands that start an alate and the terminal that attaches to one, and Window the one that puts it on your desktop. 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, and it can say one thing out loud in the conversation that scheduled it. Refer to Cron.
A chat on the Telegram bot is attached from the moment the alate starts, and not
only once somebody writes to it. That is what gives a job at three in the
morning somewhere to report to; the cost is a conversation in /sessions for
each allowed chat, whether or not anybody is using it.
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.
A client that connects opens a session, so a window that reconnects after the daemon was restarted is in a new conversation and says so.
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/skills,.agents/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.
Sandbox
Alate runs each bash command and each plugin exec command in a sandbox.
The sandbox can write only to the workspace. It can read the system files that
the command needs to run, but it cannot see your other home directories. It
also has its own process list, temporary files and home directory.
The sandbox uses Bubblewrap on Linux. Bubblewrap must be installed and the system must allow user namespaces. Alate refuses to start when it cannot make the sandbox. This is deliberate: a warning would make a resident agent run with more access than its configuration says.
The policy is outside the agent workspace, so the agent cannot give itself more access:
~/.aphid/alate/.sandbox/<name>.json
An absent file gives the strict default. This example allows a toolchain to be read and one directory to be changed:
{
"version": 1,
"enabled": true,
"network": "host",
"read_only": ["/opt/toolchain"],
"read_write": ["/var/tmp/alate-output"],
"host_environment": ["DEPLOY_TOKEN"]
}
Paths must be absolute and exist when Alate starts. network is host by
default. Set it to none to remove the network from commands and plugin HTTP
calls. It does not remove the model or gateway network of Alate itself.
On a system without Bubblewrap, or on macOS, set enabled to false in this
file to run without a sandbox. This is an explicit opt-out.
alate.json can set variables for sandboxed commands:
{
"environment": {
"MODE": "production",
"TOKEN": "${DEPLOY_TOKEN}"
}
}
A complete ${NAME} value copies the host variable named NAME. The name must
be in host_environment in the sandbox policy. $${NAME} writes the literal
text ${NAME}. Values do not expand inside larger strings and do not expand
again. A missing allowed host variable stops Alate at start.
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, "attachment_limit": 20971520, "telegram": null, "colony": null },
"environment": {}
}
| Field | Effect |
|---|---|
model | The model, by the name aphid model list shows. The first configured model when absent. An alate with no configured model fails and says to run aphid models add. |
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.attachment_limit | The largest file, in bytes, an attachment-capable gateway can receive. It is 20 MiB when absent. Set 0 to turn attachments off. |
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. |
environment | Literal variables for sandboxed commands. ${NAME} copies an allowed host variable. See Sandbox. |
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.",
"origin": {
"session": "20260810T201400-0007",
"label": "telegram: 42"
},
"since": "2026-08-10T20:14:00-03:00",
"last": "2026-08-11T09:00:00-03:00"
}
]
}
Answering back
Nobody is watching a job’s own session, so what it says there reaches its
transcript and no person. To reach one it has a send_message tool, which says
one thing in the conversation the job was scheduled in — a Telegram chat, a
colony channel, a terminal, the resident conversation. That conversation is
origin on the job, written when the job was written, and the tool takes only
the words: a job cannot choose somewhere else to write.
The tool is not gated by permissions. The destination is not the agent’s to
pick, and a question asked at three in the morning is a question nobody answers.
A conversation is found again by its id while it is still open, and by its name
after that — telegram: 42 comes back under that name when the chat reconnects,
and so does resident when the alate is restarted. A terminal that said nothing
when it attached is listed as attached, and several of them carry that one
word: a job scheduled from such a terminal is answered while the terminal is
there, and once it closes there is nothing to tell those apart, so the job is
told there is nowhere to say it. Attach with a name — aphid alate attach and
the Telegram bot both do — to be reachable tomorrow.
A message that has nowhere to go is reported to the job as a failed tool call, not swallowed. The job can then write what it found to the memory instead.
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 new job waits for the first time its schedule names after you write it.
since records that moment. A job written at 20:00 for 0 9 * * * runs at
09:00 the next morning, and not at once. Writing over a job that exists starts
its clock again in the same way.
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 listening for code/tick runs four times each second. See
Plugins.
Skills in <home>/.aphid/skills and <home>/.agents/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. |
~/.aphid/gui.sock | Where a running window is told to show itself. One for the machine, because there is one window. |
~/.aphid/gui.json | What that window remembers: its mode, its familiar, and the alate it was last pointed at. |
| 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. |