# Overview (/agent-framework)
## Looped Agent Framework [#looped-agent-framework]
```txt
██╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗ █████╗ ███████╗
██║ ██╔═══██╗██╔═══██╗██╔══██╗██╔════╝██╔══██╗ ██╔══██╗██╔════╝
██║ ██║ ██║██║ ██║██████╔╝█████╗ ██║ ██║ ███████║█████╗
██║ ██║ ██║██║ ██║██╔═══╝ ██╔══╝ ██║ ██║ ██╔══██║██╔══╝
███████╗╚██████╔╝╚██████╔╝██║ ███████╗██████╔╝ ██║ ██║██║
╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚═════╝ ╚═╝ ╚═╝╚═╝
```
### Overview [#overview]
The **Looped Agent Framework** is a framework for building and deploying AI agents as services. These are generally single-purpose agents
that sit in an infinite feedback loop. The agent waits for an event (a Discord message, a webhook, a cron tick),
does its job, delivers the result and then goes idle again.
The core idea is that *an agent is a file*. A simple config file defines the agent's purpose, the model, the tools
and the boundaries it operates within.
```yaml title="agent.yml"
handle: issue-bot # agents name themselves; you just pick the handle
description: Turns team Discord messages into GitHub issues.
model: { provider: openai-compatible, id: gpt-5.4-mini }
triggers:
- type: discord
channels: ["issues"]
skills:
- ./skills/gh-issues.md
permissions:
net: [discord.com, gateway.discord.gg, api.github.com]
run: [gh]
```
### Docker as the deployment platform [#docker-as-the-deployment-platform]
We use Docker as the deployment platform: one agent is one container, so an agent can run
on a VPS, a homelab machine or any cloud, on any OS that can run Docker. Restart policies, health checks, secrets and logs
are problems the container ecosystem solved years ago, so agents inherit those answers
instead of us reinventing them. A fleet is just more containers. The mechanics live in
[Docker run](/agent-framework/docker-run) and
[Docker compose](/agent-framework/docker-compose).
A more important reason is containment. An agent that runs unattended is eventually going to be
handed hostile input, so we built the runtime as nested layers of enforcement, where each
layer assumes the layer inside it can fail.
#### A hardened base image [#a-hardened-base-image]
We created a minimal image that contains the Deno runtime, the
framework and bash. Nothing extra. Anything that isn't in the image can't be misused, so
the attack surface stays small and every capability your agent has is one you added
deliberately. The process runs as a non-root user, and a built-in healthcheck surfaces
each agent's state in `docker ps`.
[What the base image gives you](/agent-framework/docker-run#what-the-base-image-gives-you)
has the full list.
#### A permission system built on Deno [#a-permission-system-built-on-deno]
Most runtimes give a process everything the OS
user can do. Deno works the other way around: the process starts with nothing and only
holds what you granted it at launch. The `permissions:` block in the agent file compiles
down to Deno permission flags, and the framework's own deny-by-default engine handles
what flags can't express: network egress per host, shell commands gated per executable,
secrets injected server side. This means that the boundaries you write in the agent file
are enforced at runtime. The full story is in
[The permission model](/agent-framework/permission-model).
#### The container as the outer boundary [#the-container-as-the-outer-boundary]
The layers nest: the permission engine sits
inside the Deno sandbox, which sits inside the container, and whatever slips past an
inner layer meets the next one. Bash subprocesses escape the Deno sandbox by design, and
the container is what contains them; that's also why there is no "run on the host" mode.
A misbehaving agent is one container. You can stop it and it's gone, and neither your
host nor the rest of the fleet ever feels it.
## The Manifesto [#the-manifesto]
Start with the [manifesto](https://github.com/loopedautomation/agent-framework/blob/main/MANIFESTO.md) - it's a short read and outlines the philosophy behind the framework.
## Next Steps [#next-steps]
The framework is built in the open at
[loopedautomation/agent-framework](https://github.com/loopedautomation/agent-framework).
The [examples](https://github.com/loopedautomation/agent-framework/tree/main/examples)
are complete, runnable agents, from a minimal REPL bot to a Discord to GitHub agent
deployed with `docker compose up`.
# Quick start (/agent-framework/quick-start)
This guide takes you from an empty directory to a running agent in about five minutes: you write one file, validate it, and run it. Everything runs through Docker, so there is nothing else to install.
## 0. Prerequisites [#0-prerequisites]
* [Docker](https://docs.docker.com/get-started/get-docker/) - agents run from the published base image, [`ghcr.io/loopedautomation/agent`](https://github.com/loopedautomation/agent-framework/pkgs/container/agent)
* An API key for an OpenAI-compatible or Anthropic endpoint - or a local model via Ollama, no key required
## 1. Write the agent file [#1-write-the-agent-file]
An agent is defined entirely by a single file. Create a project directory, then add the definition as `agent.yaml`:
```sh
mkdir time-bot && cd time-bot
```
```yaml
# agent.yaml
handle: time-bot
description: Answers questions, and knows what time it is.
model:
provider: openai-compatible
id: gpt-5.4-mini
purpose: |
You are a concise assistant. When asked about the current date or time,
use the current_time tool rather than guessing.
```
```yaml
# agent.yaml
handle: time-bot
description: Answers questions, and knows what time it is.
model:
provider: anthropic
id: claude-haiku-4-5
purpose: |
You are a concise assistant. When asked about the current date or time,
use the current_time tool rather than guessing.
```
* The `handle` is the identifier you use to refer to the agent. The agent chooses its own display name on first boot and announces it in a startup banner.
* Unknown keys are validation errors, so a misspelled key such as `permisions:` fails immediately instead of being silently ignored.
* To use a local model instead, use the `openai-compatible` provider and add `base_url: http://host.docker.internal:11434/v1` under `model:` - no API key is needed. (`localhost` would resolve to the container itself; on Linux, also add `--add-host=host.docker.internal:host-gateway` to the commands below.)
Every block is explained in [Agent config](/agent-framework/agent-file).
## 2. Validate it [#2-validate-it]
Validation is the same for both providers:
```sh
docker run --rm -v ./agent.yaml:/agent/agent.yaml:ro \
ghcr.io/loopedautomation/agent:latest validate /agent/agent.yaml
```
Prints the parsed identity, compiled sandbox flags and every env var the config references, with a warning for any that aren't set.
## 3. Run it [#3-run-it]
```sh
export OPENAI_API_KEY=sk-...
docker run --rm -it \
-v ./agent.yaml:/agent/agent.yaml:ro \
-e OPENAI_API_KEY \
-v time-bot-data:/data \
ghcr.io/loopedautomation/agent:latest
```
```sh
export ANTHROPIC_API_KEY=sk-ant-...
docker run --rm -it \
-v ./agent.yaml:/agent/agent.yaml:ro \
-e ANTHROPIC_API_KEY \
-v time-bot-data:/data \
ghcr.io/loopedautomation/agent:latest
```
```
Meridian (time-bot) is listening (model: gpt-5.4-mini; ctrl-d to exit)
you> what time is it?
Meridian> It's 21:14 UTC on July 3, 2026.
[ok · 2 steps · 743in/41out tokens · $0.000136]
```
Your first agent is now running locally. The image's default command runs the mounted config, and the `/data` volume holds the agent's memory and identity - persist it and the agent keeps the name it chose. Every run reports its status, step count and token usage.
## What's next [#whats-next]
Without `triggers:`, running the agent starts an interactive REPL, which is the fastest way to iterate on a `purpose`. From here you can:
* Give it triggers and the same image runs a long-lived service: [Discord](/agent-framework/discord) · [Webhook](/agent-framework/webhook) · [Cron](/agent-framework/cron)
* Teach it skills and wire up tools: [Skills](/agent-framework/skills) · [Tools](/agent-framework/tools)
* Grant it capability safely: [Permissions](/agent-framework/permissions)
* Ship it for real, from the base image to fleets and PaaS: [Docker run](/agent-framework/docker-run) · [Docker compose](/agent-framework/docker-compose)
* Generate a complete project instead of writing the files by hand - `af init` scaffolds the agent, secrets and deployment shape: [CLI](/agent-framework/cli#af-init)
* Start from a complete, runnable agent: the [gh-issues-cli example](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-cli) uses the same file shape, adds triggers, skills and permissions, and deploys with `docker compose up`
# Agent config (/agent-framework/agent-file)
Each agent is defined by a single file. The agent file describes everything about an agent: its identity, the model that runs it, the events that wake it, and the boundaries it operates within. This page walks through every block; the exhaustive field list lives in the [JSON Schema](https://github.com/loopedautomation/agent-framework/blob/main/schema/agent.json), which your editor can enforce as you type ([set it up](#editor-support)) and `af schema` prints locally.
Here is a complete agent, for orientation:
```yaml
# yaml-language-server: $schema=https://looped.sh/schema/agent.json
handle: issue-bot
description: Turns team Discord messages into GitHub issues.
model:
provider: openai-compatible
id: gpt-5.4-mini
purpose: |
You turn Discord messages into well-formed GitHub issues in myorg/myrepo,
using the gh CLI. Reply with the issue link. If a message isn't an issue
report or feature request, say so briefly instead of inventing one.
triggers:
- type: discord
channels: ["issues"]
skills:
- ./skills/gh-issues.md
permissions:
net: [api.github.com]
run: [gh]
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
memory:
scope: thread
limits:
max_steps: 15
```
Four keys are required: `handle`, `description`, `model`, and `purpose`. Everything else is optional, and unknown keys are validation errors — a misspelled `permisions:` fails immediately rather than being silently ignored.
## Identity: handle, description — and the name [#identity-handle-description--and-the-name]
`handle` is what *you* call the agent — lowercase, hyphens (`^[a-z0-9][a-z0-9-]*$`). It names the compose service, the log lines, and the agent's database file. `description` is one line: what job this agent does.
You don't choose the agent's display name. On first boot the agent names itself with a single LLM call (routed to the `model.small` role) and persists the name in its SQLite identity; the CLI prints a banner when this happens. You address the agent by its `handle`, and it signs its work with the name it chose. A fresh data volume means a fresh identity, and the agent will name itself again.
## Purpose [#purpose]
`purpose` is the agent's job description and becomes its system prompt: what it does, how it behaves, and — just as important for event-driven agents — when to stay quiet. Be specific; this is the entire brief the model works from. A narrow, concrete purpose is what lets a small model be reliable.
## Model [#model]
```yaml
model:
provider: openai-compatible # or: anthropic
id: gpt-5.4-mini
# base_url: http://localhost:11434/v1 # any compatible endpoint, e.g. Ollama
# api_key_env: OPENAI_API_KEY # names the env var; the key stays out of config
# small: gpt-5.4-nano # for cheap internal calls
# fallbacks: [gpt-5.4] # tried in order when the primary fails
```
* **`provider`** is a dialect: `openai-compatible` covers OpenAI, Ollama, vLLM, and anything speaking that API; `anthropic` is the native Anthropic API. Swapping providers is one config line — no provider is load-bearing.
* **`base_url`** points `openai-compatible` at any endpoint. Local models need no key.
* **`api_key_env`** names the env var holding the key; the key itself stays out of the config. Defaults to `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` per provider.
* **`small`** is the model for cheap internal calls (the naming ritual, summaries). Defaults to the main `id` — set it to something tiny and these calls round to free.
* **`fallbacks`** names model ids to try in order when the primary fails — validated today, with the runtime chain still landing ([Models](/agent-framework/models#when-the-provider-fails)).
The full model story — dialects, keys, local endpoints, retries — is [Models](/agent-framework/models).
## Memory [#memory]
```yaml
memory:
scope: thread # default: none
persistent: true # default: false
```
`none` (the default) starts every run fresh. `thread` persists conversation history per conversation key — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session — so follow-ups work ("make it weekly instead"). `persistent: true` gives the agent `remember`/`recall`/`list_memories`/`forget` tools — facts that survive across conversation keys and container restarts, not just one thread's transcript. Both live in the agent's own SQLite file, nowhere else, and compose freely. The full story, including what the model sees in its system prompt and how it's audited, is in [Memory](/agent-framework/memory).
## Limits [#limits]
```yaml
limits:
max_steps: 20 # default: 20 inner-loop iterations
```
These limits protect unattended operation and are on by default. Every run ends with a typed status:
| Status | Meaning |
| ----------------- | ----------------------------------------- |
| `ok` | The agent finished its job. |
| `error_max_steps` | The run hit `limits.max_steps` LLM calls. |
| `error_provider` | The provider failed after retries. |
A run that exceeds its budget ends immediately with the matching status, so an unattended agent can only spend what you've allowed. Each run's status, step count and token usage are recorded in [the data volume](/agent-framework/docker-run#persistence-the-data-volume).
## Env [#env]
```yaml
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
```
The `env:` block grants environment variables to tools and MCP servers — and only those; subprocesses never inherit the agent process's ambient environment. Values may be `${VAR}` references, resolved at startup from the process env, then from `/run/secrets/` (Docker Compose file secrets). A missing reference fails at startup, before any event is handled. Secrets never enter the model's context — the full story is in [Permissions](/agent-framework/permissions#secrets).
## The blocks with their own pages [#the-blocks-with-their-own-pages]
* **`triggers:`** — the events that wake the agent. With triggers, `af run` starts a long-lived service; without, an interactive REPL. → [Discord](/agent-framework/discord) · [Slack](/agent-framework/slack) · [Telegram](/agent-framework/telegram) · [Webhook](/agent-framework/webhook) · [Cron](/agent-framework/cron)
* **`skills:`** — markdown files that teach the agent how to use something well; capability stays with the config. → [Skills](/agent-framework/skills)
* **`tools:`** — capability beyond the natives: MCP servers, and tool search to keep their schemas out of context. → [Tools](/agent-framework/tools)
* **`permissions:`** — deny-by-default allowlists for hosts, executables, and paths. Omit the block and the agent can touch nothing. → [Permissions](/agent-framework/permissions)
* **`memory:`** — conversation history (`scope`) and facts that survive across conversations and restarts (`persistent`). → [Memory](/agent-framework/memory)
## Validating [#validating]
`af validate agent.yaml` parses the file, prints the identity, triggers, compiled sandbox flags, and every env var referenced — warning on any that aren't set.
### Editor support [#editor-support]
Add this as the first line of any agent file and your editor (VS Code, JetBrains, Neovim — anything running yaml-language-server) validates as you type: autocomplete on every key, hover docs from the field descriptions, red squiggles on typos:
```yaml
# yaml-language-server: $schema=https://looped.sh/schema/agent.json
```
The schema is generated from the same source of truth the runtime enforces ([schema/agent.json](https://github.com/loopedautomation/agent-framework/blob/main/schema/agent.json), kept current by CI), so nothing can exist in the gap between "accepted" and "documented". `af schema` prints it locally.
# The permission model (/agent-framework/permission-model)
A service agent runs at 3am, triggered by a webhook, on a machine nobody is watching.
There is no one to ask "may I run this?", so the question has to be answered before the
agent starts. That is what the `permissions:` block is for: you declare once, in config,
what the agent is allowed to touch, and everything else is denied. There is no prompt at
runtime. When the agent tries something outside its grants, the denial goes back to it as
an ordinary tool result and it carries on with that as context for its next turn.
The default is deny. An agent with no `permissions:` block can touch nothing, and there
is no way to grant more while the agent is running. Widening a boundary means editing the
file and redeploying, so a capability change gets reviewed and versioned like any other
config change.
## The four permission types [#the-four-permission-types]
Permissions come in four axes: `net`, `run`, `read` and `write`. Each one is an
allowlist, and each native tool only exists for the agent when its axis grants something.
This means that no unused tool schema takes up context, and there is nothing sitting
there to misuse.
### net: which hosts the agent can call [#net-which-hosts-the-agent-can-call]
```yaml
permissions:
net: [api.github.com, "*.internal.example.com"]
```
With this block, `http_request` can reach `api.github.com` and any subdomain of
`internal.example.com`, such as `mcp.internal.example.com`. A request to any other host
comes back as `permission denied: net access to "evil.com" is not in the agent's
permissions.net allowlist`. The wildcard covers subdomains only; `internal.example.com`
itself needs its own entry. With no `net:` list, the `http_request` tool does not exist
for the agent at all.
### run: which executables the agent can spawn [#run-which-executables-the-agent-can-spawn]
```yaml
permissions:
run: [gh, grep]
```
With this block, `run_bash` can execute `gh issue list | grep bug`. The framework does
not trust the shell: it extracts every executable from pipes and chains and checks each
one against the list, so `gh issue list | curl evil.com` is denied because `curl` is
missing from the allowlist. Command substitution (`$(...)`, backticks, `<(...)`) is
rejected outright, because there is no way to check what is inside it before it runs.
Executables are matched by basename, so `/usr/bin/gh` counts as `gh`.
### read and write: which paths the agent can touch [#read-and-write-which-paths-the-agent-can-touch]
```yaml
permissions:
read: [/workspace]
write: [/workspace/out]
```
With this block, `read_file` can open `/workspace/notes.md` and `write_file` can create
`/workspace/out/report.md`. Reading `/etc/passwd` is denied, and so is the traversal
attempt `/workspace/../etc/passwd`, because paths are normalized before the check. Writes
outside `/workspace/out` are denied, including the rest of `/workspace`.
An agent with an empty `permissions:` block carries only `current_time`, plus
`read_skill` if it has [skills](/agent-framework/skills). The full toolset and what makes each tool
appear is in [Tools](/agent-framework/tools); syntax, matching rules and secrets are in
[Permissions](/agent-framework/permissions).
## The three layers [#the-three-layers]
We don't trust any single boundary to hold. Enforcement nests in three layers, and each
layer assumes the one inside it can fail.
1. **The permission engine.** Framework code checks every native tool call against the
allowlists above, and every decision, allowed and denied, lands in the audit trail.
2. **The Deno sandbox.** The agent process itself is launched with only the rights it
needs: in the base image, reads scoped to `/agent`, `/skills`, `/data` and
`/run/secrets`, writes to `/data` and subprocess spawning to `bash` alone. The runtime
enforces this underneath the framework's own code, so a bug in the framework can't
grant an access the runtime was never given. `af flags agent.yaml` prints the compiled
flag set for a config.
3. **The container.** This is the outer wall and the unit of isolation. `bash`
subprocesses escape the Deno sandbox by design, and the container is what contains
them. That is also why there is no "run on the host" mode: the framework refuses to
run where its outermost layer is missing.
Subprocesses and MCP servers receive only the env vars their config block grants, plus
`PATH`/`HOME`, and secret values are injected server side, so they never enter the
model's context.
## Where the boundaries stop today [#where-the-boundaries-stop-today]
The model above is honest about its edges, and you should know where they are before you
rely on it.
**An MCP server's network traffic bypasses `permissions.net`.** The engine checks hosts
for the native `http_request` tool; whatever outbound calls an MCP server makes happen
outside it. When you declare a server under `tools.mcp`, you are trusting where it talks
to. Your controls on the tool side are the `include:` filter (a tool you didn't include
does not exist for the agent), the `readonly:` flag and the scoped `env:` block, and
every MCP call lands in the audit trail; the server's own egress is bounded by the
container.
**Network egress is open below the app layer.** The container runs with Deno's network
permission unrestricted, so per-host enforcement happens only in the permission engine.
Anything that runs outside the engine, a `bash` subprocess or an MCP server process, can
reach any host the container can. A `gh` you allowed will talk to whatever it wants. If
egress matters for an agent, restrict it at the container layer with your network
setup.
**The image's sandbox flags are shared.** The published image launches every agent with
the same Deno flag set, and the per-agent compiled flags from `af flags` apply when you
build your own entrypoint. Inside the shipped container, what varies per agent is the
permission engine's allowlists.
**`run` matches by basename.** `run: [gh]` allows any executable named `gh`, wherever it
lives. Inside the hardened base image that is fine in practice; if you derive an image
that widens the writable paths, keep in mind that the container is the backstop.
The result is that you can run an agent unattended and know its worst case in advance:
the agent can reach exactly what its grants say, the runtime and the container hold that
boundary underneath the framework's own code, and the remaining edges are outlined
above. The config itself is hard to get wrong without noticing, because unknown keys are
rejected at load time and anything you leave out is denied.
# Docker run (/agent-framework/docker-run)
One agent runs in one container, and the container is the unit of deployment, isolation and scaling. An agent behaves the same on a single machine as it does in a fleet. To run several agents together, see [Docker compose](/agent-framework/docker-compose).
## Quick run [#quick-run]
The [CLI](/agent-framework/cli) starts the container for you — point it at the agent file:
```sh
af up -d agent.yaml # detached; af ps to inspect, af down to stop
af up agent.yaml # foreground, logs streaming, ctrl-c stops
af run agent.yaml # interactive: REPL without triggers, service with
```
`af up` mounts the config and any skills read-only, attaches the `-data` volume, passes the `.env` sitting next to the agent file, publishes the status surface on an ephemeral loopback port, and runs the container read-only. `af up --dry-run` prints the exact command instead of running it.
## The base image [#the-base-image]
We publish the base image to GitHub Packages as **`ghcr.io/loopedautomation/agent`**. It's public, built for amd64 and arm64, and rebuilt on every release: each version gets its own immutable tag, and `:latest` always points at the newest release. An agent is the YAML mounted onto that image, and every `af` command expands to a plain `docker run` against it — the [CLI page shows the exact expansion](/agent-framework/cli#under-the-hood), and `af up --dry-run agent.yaml` prints it for your agent, ready for a systemd unit or a runbook.
If you'd rather build the image yourself, run `docker build -f images/agent/Dockerfile -t ghcr.io/loopedautomation/agent:latest .` from the repo root.
## File-less deploys: config via env var [#file-less-deploys-config-via-env-var]
Some platforms make environment variables easy and file mounts awkward. Coolify, Railway, Fly and any other platform where a deploy is an image plus env vars all have this shape. For these, you can put the YAML itself in `AF_AGENT_CONFIG` and deploy the stock image with no files at all; the agent reads its definition from the env var. If you set both the env var and a mounted `/agent/agent.yaml`, the agent refuses to start rather than guessing which one you meant.
Skills need real files, so this route only works for agents without them; bake a custom image if your agent has skills. We'd treat it as a last resort for platforms without file mounts. For compose deployments, keep configuration out of the environment and use the [single-file `configs:` shape](/agent-framework/docker-compose#one-compose-file-the-whole-agent-inline) instead.
## The custom-image story [#the-custom-image-story]
The Dockerfile defines the environment and the YAML defines the agent. If your agent needs a CLI the base image doesn't carry, add a layer:
```dockerfile
FROM ghcr.io/loopedautomation/agent:latest
USER root
RUN apk add --no-cache github-cli
USER looped
# Optional: bake the config and skills in so the image is self-contained
COPY --chown=looped:looped skills/gh-issues.md /skills/gh-issues.md
COPY --chown=looped:looped agent.yaml /agent/agent.yaml
```
Build it and point the CLI at it:
```sh
docker build -t my-agent .
af up -d --image my-agent agent.yaml
```
[`examples/gh-issues-cli`](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-cli) is the complete pattern, with the Dockerfile, the compose.yaml and an `.env.example`, deployed with [Docker compose](/agent-framework/docker-compose).
## What the base image gives you [#what-the-base-image-gives-you]
* **Hardened by default**: the process runs as `looped` (uid 10001), a non-root user, and the image contains bash for `run_bash` and nothing else. No browser, no extras. The compose examples add `read_only: true` and a tmpfs on top.
* **The Deno sandbox as layer 1**: reads are scoped to `/agent`, `/skills`, `/data` and `/run/secrets`, writes to `/data` only, and subprocess spawning is limited to bash, which the permission engine then gates per executable ([the layers](/agent-framework/permissions#the-layers)).
* **Volumes**: `/data` holds the agent's SQLite database, with its sessions, runs, audit trail and chosen name. Persist this volume; a fresh volume gives the agent a fresh identity.
* **Health**: a `HEALTHCHECK` is wired to the status surface, so `docker ps` shows `healthy`.
* **Ports**: `8080` for the webhook trigger (if configured) and `9090` for the status surface.
## The status surface [#the-status-surface]
Every service agent exposes:
* `GET /healthz` - liveness and identity (handle, chosen name, model, triggers, uptime). Unauthenticated.
* `GET /runs` and `GET /audit` - the run history and the permission decisions. Loopback-only unless `AF_STATUS_TOKEN` is set, and then they take bearer-token access.
`af ps` shows each agent's status address — `af up` publishes port 9090 on an ephemeral loopback port so agents never collide:
```sh
af ps # HANDLE · STATE · STATUS · STATUS ADDR
curl -s 127.0.0.1:55031/healthz | jq # the addr af ps printed
```
`AF_STATUS_HOST` and `AF_STATUS_PORT` override the bind. The base image sets the host to `0.0.0.0`, so publish the port loopback-only, the way `af up` and the compose examples do.
## Persistence: the data volume [#persistence-the-data-volume]
Each agent owns one SQLite file: `/data/.db` in the container, or wherever `AF_DATA_DIR` points (locally it defaults to `.looped/`). It holds:
* **sessions/messages** - the conversation history per conversation key (when `memory.scope: thread`)
* **memories** - facts the agent chose to remember, keyed by name, visible across every conversation key (when `memory.persistent: true`) — see [Memory](/agent-framework/memory)
* **runs** - every run, with its trigger, input, status, steps, tokens and timestamps
* **audit** - every permission decision, allowed and denied, plus every memory write and delete
* **identity** - the name the agent chose on first boot
This means the agent's full history sits in one file you can query: everything the agent did, including the actions its permissions denied. Persist the volume; with a fresh one the agent starts over and names itself again.
## Secrets [#secrets]
A `.env` file next to the agent file is the simple path — `af up` passes it automatically (`--env-file` points elsewhere), and warns about any `${VAR}` the config references that the file doesn't supply. A missing reference fails at startup. Compose `secrets:` files resolve the same way; see [Docker compose](/agent-framework/docker-compose#secrets). Secrets are injected into tools server side and never enter the model's context ([Permissions](/agent-framework/permissions#secrets)).
# Docker compose (/agent-framework/docker-compose)
Docker Compose is how you run one or many agents as a fleet. Each agent is one service block, with its image, volume, env file and restart policy written down and versioned alongside the agent itself.
## One service block per agent [#one-service-block-per-agent]
```yaml
services:
gh-issues-cli:
image: ghcr.io/loopedautomation/agent:latest
volumes:
- ./agent.yaml:/agent/agent.yaml:ro
- gh-issues-cli-data:/data
env_file: .env
restart: unless-stopped
volumes:
gh-issues-cli-data:
```
`af init --deploy compose` generates this shape, plus a Dockerfile when the agent needs [extra CLIs in the image](/agent-framework/docker-run#the-custom-image-story). The generated examples also add `read_only: true` and a tmpfs, which is hardening on top of what [the base image already gives you](/agent-framework/docker-run#what-the-base-image-gives-you).
[`examples/gh-issues-cli`](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-cli) is the complete pattern:
```sh
cd examples/gh-issues-cli && cp .env.example .env # fill in your keys
docker compose up -d
```
## One compose file: the whole agent, inline [#one-compose-file-the-whole-agent-inline]
A top-level `configs:` element collapses a compose deploy to a single file. The agent's config is defined inline and mounted into the container at `/agent/agent.yaml`. There is no separate file on disk and no configuration passed through the environment:
```yaml
# compose.yaml - no agent.yaml anywhere
configs:
agent-yaml:
content: |
handle: time-bot
description: Answers questions, and knows what time it is.
model:
provider: openai-compatible
id: gpt-5.4-mini
purpose: |
You are a concise assistant. Use current_time rather than guessing.
services:
time-bot:
image: ghcr.io/loopedautomation/agent:latest
configs:
- source: agent-yaml
target: /agent/agent.yaml
env_file: .env
volumes:
- time-bot-data:/data
restart: unless-stopped
volumes:
time-bot-data:
```
`af init --deploy compose-inline` generates this shape. Two things to know. Inline `content:` requires Docker Compose v2.23.1 or newer. And env references *inside the embedded config* must be written `$${VAR}` (double dollar), so that compose passes them through for the runtime to resolve instead of substituting the value into the config at deploy time.
## Fleets [#fleets]
A fleet is the same file with more entries: each agent is one more service block with its own config, volume and permissions. When a second job needs doing, run a second agent alongside the first rather than widening the first agent's scope.
## Secrets [#secrets]
Compose `secrets:` are mounted at `/run/secrets/` and resolve the same way env vars do: a config reference like `${GITHUB_TOKEN}` checks the env first and then the secrets file. `env_file: .env` covers simple setups. Secrets are injected into tools server side and never enter the model's context ([Permissions](/agent-framework/permissions#secrets)).
# CLI (/agent-framework/cli)
`af` is the framework's CLI, published to JSR as [`@looped/af`](https://jsr.io/@looped/af). Install it once with Deno:
```sh
deno install -g --allow-read --allow-write --allow-env --allow-net --allow-run=bash,docker -n af jsr:@looped/af
```
The CLI orchestrates Docker under the hood: agents always execute in the published container, never on your machine. Pointing `af` at an agent file starts the container with the config mounted, the data volume attached and the env file passed. Config paths default to `./agent.yaml`.
```
af init [name] Scaffold a new agent project (agent, secrets, deployment)
af run [agent.yaml] Run one agent in Docker, interactive (REPL without triggers)
af up [agent.yaml...] Start agents in Docker — foreground; -d to detach
af ps List af containers
af down [target...] Stop and remove af containers (files or handles; none = all)
af validate [agent.yaml] Validate an agent definition
af flags [agent.yaml] Print compiled Deno permission flags
af schema Print the agent.yaml JSON Schema
af discord-invite
Print the bot's OAuth invite URL (no bitfield math)
```
## Under the hood [#under-the-hood]
Every `af` command that runs an agent expands to a plain `docker run` on the [published base image](/agent-framework/docker-run) — no daemon of its own, no state outside Docker. `af up -d agent.yaml` is exactly this:
```sh
docker run -d --restart unless-stopped \
--name af-agent \
--label af.agent=agent \
-v ./agent.yaml:/agent/agent.yaml:ro \
--env-file .env \
-v agent-data:/data \
-p 127.0.0.1:0:9090 \
--read-only --tmpfs /tmp \
ghcr.io/loopedautomation/agent:0.3.0
```
That's the config and any `skills:` mounted read-only, the `-data` volume so identity survives restarts, the `.env` next to the agent file, the [status surface](/agent-framework/docker-run#the-status-surface) on an ephemeral loopback port so fleets never collide, and a read-only root filesystem. The image tag matches the CLI's version — never `:latest`, so a cached image can't drift out from under a newer CLI (`--image` overrides). `af ps` and `af down` find containers by the `af.agent` label. Because it's all plain Docker, everything you know still works: `docker logs af-`, `docker stats`, restart policies, volume backups.
`--dry-run` on `run`/`up` prints the exact command for your agent instead of executing it — useful for pasting into a systemd unit or a runbook.
## af init [#af-init]
`af init` scaffolds a complete agent project into `/`. It's interactive by default, and every question is also a flag, so you can script it as one line:
```sh
af init issue-helper --trigger discord --provider openai-compatible \
--deploy compose --clis gh
```
| Flag | Choices | |
| --------------------- | ----------------------------------------------------------------- | ------------------------------------------------------- |
| `--trigger` | `discord` `webhook` `cron` `none` | `none` = REPL agent |
| `--provider` | `openai-compatible` `anthropic` `local` | `local` = openai-compatible + Ollama `base_url`, no key |
| `--deploy` | `local` `docker` `compose` `compose-inline` `paas-git` `paas-env` | see below |
| `--model` | any model id | sensible default per provider |
| `--clis` | comma-separated executables | adds a Dockerfile layer + `permissions.run` |
| `--handle` / `[name]` | lowercase, hyphens | what you call the agent |
| `--dir` | a directory | where to scaffold (default `.`) |
Every shape generates `agent.yaml`, `.env.example` (every secret the config references, ready to copy to `.env`) and a `README.md` with the exact deploy steps. Each deploy shape then adds its own files:
* **`local`** - nothing more; you `af validate` and `af run`.
* **`docker`** - the `docker run` command in the README, with the mounted config, env file and data volume.
* **`compose`** - a `compose.yaml`, a `Dockerfile` when `--clis` needs one and a `.gitignore`.
* **`compose-inline`** - a single `compose.yaml` with the agent config [defined inline](/agent-framework/docker-compose#one-compose-file-the-whole-agent-inline) in a top-level `configs:` element and mounted at `/agent/agent.yaml`.
* **`paas-git`** - a `Dockerfile` and `compose.yaml` for platforms that build from a repo (Coolify, for example): you push, connect, set the env vars and deploy.
* **`paas-env`** - for platforms where a deploy is an image plus env vars: the stock image with the config in `AF_AGENT_CONFIG` and no files at all.
## af run [#af-run]
`af run` starts one agent in Docker, interactive and in the foreground (`docker run -it --rm`): a long-lived service if the config has `triggers:` and a REPL if it doesn't. On first boot the agent picks its name and prints the birth banner. Ctrl-c stops the container; the `-data` volume stays, so the identity survives.
Inside the published image the entrypoint calls the same command with `AF_CONTAINER=1` set, which makes `run` execute the agent in-process — that branch *is* the container entrypoint. Set `AF_CONTAINER=1` yourself only for framework development.
## af up [#af-up]
`af up` starts one container per agent file. The foreground default streams every agent's logs with a colored `[handle]` prefix and stops them all on ctrl-c; `-d` detaches with `--restart unless-stopped` and waits until each agent answers on its status port:
```
$ af up -d issue-bot/agent.yaml helpdesk/agent.yaml
✓ issue-bot running · status http://127.0.0.1:55031
✓ helpdesk running · status http://127.0.0.1:55047
af ps to inspect · af down to stop
```
For every agent it mounts the config and any `skills:` read-only, attaches the `-data` volume, passes the `.env` next to the agent file (`--env-file` overrides), publishes the [status surface](/agent-framework/docker-run#the-status-surface) on an ephemeral loopback port and webhook trigger ports directly, and runs the container `--read-only`. `--image` overrides the image; `--dry-run` prints the docker command(s) and starts nothing. A handle that already has a container fails loudly — `af down` it first.
## af ps [#af-ps]
`af ps` lists the af-managed containers (running or stopped, discovered by the `af.agent` label) with their state, uptime and status-surface address.
## af down [#af-down]
`af down` gracefully stops and removes af containers — pass agent files or handles, or nothing for all of them. Data volumes are never removed: a lost volume is a fresh identity.
## af validate [#af-validate]
```
✓ agent.yaml is a valid agent definition
handle: issue-bot
model: openai-compatible / gpt-5.4-mini
triggers: discord
sandbox: --allow-net=api.github.com --allow-run=gh
env refs: OPENAI_API_KEY, DISCORD_BOT_TOKEN, GITHUB_TOKEN
⚠ not set in this environment: GITHUB_TOKEN
```
`af validate` parses the config (unknown keys are hard errors) and prints the identity, the triggers, the compiled sandbox flags and every env var the config references, with a warning on any that aren't set in the environment.
## af flags [#af-flags]
`af flags` prints the Deno permission flags the config's `permissions:` block compiles to, which is [layer 1 of the sandbox](/agent-framework/permissions#the-layers):
```
--allow-net=api.github.com --allow-run=gh
```
## af schema [#af-schema]
`af schema` prints the agent.yaml [JSON Schema](https://github.com/loopedautomation/agent-framework/blob/main/schema/agent.json). It's the same schema the runtime enforces and the one [editors validate against](/agent-framework/agent-file#editor-support).
## af discord-invite [#af-discord-invite]
`af discord-invite` prints the bot's OAuth invite URL with the correct scopes and permissions (View Channels, Send Messages and Read Message History), so you don't have to do the bitfield math yourself. It needs the config, to find the Discord trigger's `token_env`, and that token set in the environment; it looks up the application id from the token. It's part of the [Discord setup](/agent-framework/discord#setup).
# Webhook (/agent-framework/webhook)
The `webhook` trigger gives the agent an HTTP endpoint: POST an input and receive the run result back. It is the integration point for everything that isn't a chat platform — other services, scripts, schedulers, or your own UI.
```yaml
triggers:
- type: webhook
# path: / (default)
# port: 8080 (default)
token_env: WEBHOOK_TOKEN # required — bearer auth, deny by default
```
```yaml
configs:
agent-yaml:
content: |
handle: task-bot
description: Runs tasks submitted over HTTP.
model:
provider: openai-compatible
id: gpt-5.4-mini
purpose: |
You are a concise assistant. Complete the submitted task and
reply with the result.
triggers:
- type: webhook
token_env: WEBHOOK_TOKEN
memory:
scope: thread
services:
task-bot:
image: ghcr.io/loopedautomation/agent:latest
configs:
- source: agent-yaml
target: /agent/agent.yaml
env_file: .env # WEBHOOK_TOKEN and the model's API key
ports:
- "8080:8080"
volumes:
- task-bot-data:/data
restart: unless-stopped
volumes:
task-bot-data:
```
Call it:
```sh
curl -s localhost:8080 \
-H "authorization: Bearer $WEBHOOK_TOKEN" \
-H "content-type: application/json" \
-d '{"input": "run: echo hello", "conversation_id": "demo"}'
```
The response is the run result: `{"status": "ok", "reply": "...", "steps": 2}`. Pass the same `conversation_id` to continue a conversation (with `memory.scope: thread` — [Memory](/agent-framework/agent-file#memory)); omit it for one-shot runs.
`token_env` is required — an unauthenticated endpoint contradicts deny-by-default. The token resolves at startup, and a missing env var fails right there, before the endpoint ever accepts a call.
Every call lands in the agent's [run history](/agent-framework/docker-run#persistence-the-data-volume) with its status, steps and tokens.
# Cron (/agent-framework/cron)
The `cron` trigger runs the agent on a schedule rather than in response to an external event:
```yaml
triggers:
- type: cron
schedule: "0 9 * * 1" # every Monday 09:00
prompt: Post a summary of open issues.
```
```yaml
configs:
agent-yaml:
content: |
handle: summary-bot
description: Posts a weekly summary of open issues.
model:
provider: openai-compatible
id: gpt-5.4-mini
purpose: |
Each run, summarize the open issues and deliver the summary.
triggers:
- type: cron
schedule: "0 9 * * 1" # every Monday 09:00
prompt: Post a summary of open issues.
services:
summary-bot:
image: ghcr.io/loopedautomation/agent:latest
configs:
- source: agent-yaml
target: /agent/agent.yaml
env_file: .env # the model's API key
volumes:
- summary-bot-data:/data
restart: unless-stopped
volumes:
summary-bot-data:
```
Each tick runs the agent with `prompt` as input. Results are logged and recorded in the [run history](/agent-framework/docker-run#persistence-the-data-volume) — a configurable result sink is planned; until then, when the result needs to go somewhere, have the agent deliver it itself through an allowlisted API or CLI ([Permissions](/agent-framework/permissions)).
Every run reports its status, steps and tokens, and [`limits`](/agent-framework/agent-file#limits) cap what an unattended schedule can spend.
## Multiple triggers [#multiple-triggers]
`triggers:` is a list, and an agent can declare as many as it needs, including two of the same type. A daily prompt and a weekly prompt can live side by side:
```yaml
triggers:
- type: cron
schedule: "0 9 * * *" # every day 09:00
prompt: Post a summary of yesterday's open issues.
- type: cron
schedule: "0 9 * * 1" # every Monday 09:00
prompt: Post a summary of the past week.
```
This also works across types: the same agent can listen on Discord, serve a webhook and run a schedule at the same time. Whatever wakes it, the same purpose, permissions and run history apply, and conversation keys stay per source, so a Discord thread and a webhook caller never share memory.
# Discord (/agent-framework/discord)
Triggers turn an agent into a long-lived service that waits for events, acts on them, delivers the result, and goes idle. The `discord` trigger connects that loop to a Discord server: the agent watches channels and replies in-channel to the messages that wake it.
```yaml
triggers:
- type: discord
channels: ["issues"] # names or ids; omit for all channels
# require_mention: true # only respond when @-mentioned
# token_env: DISCORD_BOT_TOKEN (default)
# from_users: ["amin", "ratul"] # only handle these authors (user ids or usernames)
# reply_channel: "1522..." # post replies here instead of the source channel
# allow_silence: true # a reply of exactly __NO_REPLY__ posts nothing
```
```yaml
configs:
agent-yaml:
content: |
handle: issue-bot
description: Turns team Discord messages into GitHub issues.
model:
provider: openai-compatible
id: gpt-5.4-mini
purpose: |
You turn Discord messages in the issues channel into well-formed
GitHub issues and reply with the issue link.
triggers:
- type: discord
channels: ["issues"] # names or ids; omit for all channels
memory:
scope: thread
services:
issue-bot:
image: ghcr.io/loopedautomation/agent:latest
configs:
- source: agent-yaml
target: /agent/agent.yaml
env_file: .env # DISCORD_BOT_TOKEN and the model's API key
volumes:
- issue-bot-data:/data
restart: unless-stopped
volumes:
issue-bot-data:
```
## Setup [#setup]
Setting up a Discord bot takes about 15 minutes:
1. In the [Discord Developer Portal](https://discord.com/developers/applications), create a New Application with a Bot.
2. Enable the **Message Content Intent** under Privileged Gateway Intents. Without it, messages arrive empty — this is the most common setup failure.
3. Copy the bot token and export it: `export DISCORD_BOT_TOKEN=...`
4. Run `af discord-invite agent.yaml` to print a ready-made invite URL with the correct scopes and permissions, then open it and invite the bot to your server.
5. Run the agent: `af run agent.yaml`
The agent replies in-channel to the triggering message; conversations are keyed per channel or thread, and `memory.scope: thread` continues them ([Memory](/agent-framework/agent-file#memory)). It ignores bots, itself, and empty messages; long replies split at Discord's 2000-char limit.
## Observer agents [#observer-agents]
Three optional keys together turn the trigger from a chatbot into an observer — an agent that watches channels, reacts to specific people, and reports elsewhere (a review bot, a moderation assistant, a coach):
* `from_users` — handle only these authors. The filter runs *before* the model is called: everyone else's messages are dropped in the trigger and never reach the provider.
* `reply_channel` — deliver replies to a dedicated channel instead of the source. Out-of-channel replies quote the triggering message and link back to it.
* `allow_silence` — let the agent say nothing. Instruct it in `purpose` to answer with exactly `__NO_REPLY__` when it has no feedback; the trigger then posts nothing instead of a "looks fine" reply on every message.
Every run — replied or silent — lands in the agent's [run history](/agent-framework/docker-run#persistence-the-data-volume) with its status, steps and tokens.
# Slack (/agent-framework/slack)
Triggers turn an agent into a long-lived service that waits for events, acts on them, delivers the result, and goes idle. The `slack` trigger connects that loop to a Slack workspace over Socket Mode — an outbound WebSocket, so the agent needs no public endpoint. It watches channels and replies in a thread under the messages that wake it.
```yaml
triggers:
- type: slack
channels: ["help"] # names or ids; omit for all channels the bot is in
# require_mention: true # only respond when @-mentioned (DMs always respond)
# token_env: SLACK_BOT_TOKEN (default)
# app_token_env: SLACK_APP_TOKEN (default)
# from_users: ["U0HAPPY"] # only handle these authors (Slack user ids)
# reply_channel: "C0REVIEW" # post replies here instead of the source thread
# allow_silence: true # a reply of exactly __NO_REPLY__ posts nothing
```
## Setup [#setup]
Setting up the Slack app takes about 10 minutes:
1. In [api.slack.com/apps](https://api.slack.com/apps), create a new app (from scratch) in your workspace.
2. Under **Socket Mode**, enable it and generate an **app-level token** with the `connections:write` scope — this is `SLACK_APP_TOKEN` (`xapp-…`).
3. Under **OAuth & Permissions**, add the bot scopes `chat:write`, `channels:history`, `groups:history`, `im:history`, and `channels:read`, then install the app to the workspace. The **bot token** it produces is `SLACK_BOT_TOKEN` (`xoxb-…`).
4. Under **Event Subscriptions**, enable events and subscribe to the bot events `message.channels`, `message.groups`, and `message.im`. Missing event subscriptions are the most common setup failure — the socket connects but nothing arrives.
5. Export both tokens, invite the bot to a channel (`/invite @your-bot`), and run the agent: `af run agent.yaml`
Replies go into a thread under the triggering message; conversations are keyed per thread (a DM is one rolling conversation), and `memory.scope: thread` continues them ([Memory](/agent-framework/agent-file#memory)). The agent ignores bots, itself, message edits, and empty messages; long replies split at Slack's recommended 4000-char limit. If you change scopes later, reinstall the app — Slack applies them only on install.
## Observer agents [#observer-agents]
Three optional keys together turn the trigger from a chatbot into an observer — an agent that watches channels, reacts to specific people, and reports elsewhere (a review bot, a moderation assistant, a coach):
* `from_users` — handle only these authors (Slack user ids, the `U…` kind). The filter runs *before* the model is called: everyone else's messages are dropped in the trigger and never reach the provider.
* `reply_channel` — deliver replies to a dedicated channel instead of the source thread. Out-of-channel replies quote the triggering message and link back to it.
* `allow_silence` — let the agent say nothing. Instruct it in `purpose` to answer with exactly `__NO_REPLY__` when it has no feedback; the trigger then posts nothing instead of a "looks fine" reply on every message.
Every run — replied or silent — lands in the agent's [run history](/agent-framework/docker-run#persistence-the-data-volume) with its status, steps and tokens.
# Telegram (/agent-framework/telegram)
Triggers turn an agent into a long-lived service that waits for events, acts on them, delivers the result, and goes idle. The `telegram` trigger connects that loop to Telegram over Bot API long-polling — outbound HTTPS only, so the agent needs no public endpoint. It watches chats and replies to the messages that wake it.
```yaml
triggers:
- type: telegram
# chats: ["-1001234567890"] # chat ids, group titles, or @usernames; omit for all
# require_mention: true # only respond when @-mentioned (private chats always respond)
# token_env: TELEGRAM_BOT_TOKEN (default)
# from_users: ["gtchax"] # only handle these authors (user ids or usernames)
# reply_chat: "-100987..." # post replies here instead of the source chat
# allow_silence: true # a reply of exactly __NO_REPLY__ posts nothing
```
## Setup [#setup]
This is the fastest channel to set up — about 2 minutes:
1. Message [@BotFather](https://t.me/BotFather) on Telegram, send `/newbot`, and follow the prompts.
2. Copy the token it gives you and export it: `export TELEGRAM_BOT_TOKEN=...`
3. Run the agent: `af run agent.yaml` — then message your bot.
For group chats, one more thing: bots have **privacy mode** on by default, so in groups they only receive messages that @-mention them or reply to them. Either that's what you want (pair it with `require_mention: true`), or turn it off via BotFather's `/setprivacy` and re-add the bot to the group.
The agent replies in-chat to the triggering message; conversations are keyed per chat, and `memory.scope: thread` continues them ([Memory](/agent-framework/agent-file#memory)). It ignores bots and empty messages; long replies split at Telegram's 4096-char limit. Private chats always address the bot — `require_mention` only gates groups.
## Observer agents [#observer-agents]
Three optional keys together turn the trigger from a chatbot into an observer — an agent that watches chats, reacts to specific people, and reports elsewhere (a review bot, a moderation assistant, a coach):
* `from_users` — handle only these authors (user ids or usernames). The filter runs *before* the model is called: everyone else's messages are dropped in the trigger and never reach the provider.
* `reply_chat` — deliver replies to a dedicated chat instead of the source. Out-of-chat replies quote the triggering message and link back to it when the source is a supergroup.
* `allow_silence` — let the agent say nothing. Instruct it in `purpose` to answer with exactly `__NO_REPLY__` when it has no feedback; the trigger then posts nothing instead of a "looks fine" reply on every message.
Every run — replied or silent — lands in the agent's [run history](/agent-framework/docker-run#persistence-the-data-volume) with its status, steps and tokens.
# Skills (/agent-framework/skills)
A skill is a markdown file that teaches the agent how to use something well. A skill carries knowledge and nothing else: it cannot grant permissions, and the config's `permissions:` block stays the sole authority over what the agent is allowed to do. This means that the worst a bad skill can be is misleading documentation.
```yaml
skills:
- ./skills/gh-issues.md
permissions:
run: [gh] # the grant that makes gh runnable
```
This split is how you integrate almost anything: the [custom image](/agent-framework/docker-run#the-custom-image-story) provides the binary, the skill explains how to use it and the [permissions](/agent-framework/permissions) block allows it to run. For most integrations, you don't need an MCP server. A CLI and a well written skill can go a long way.
## Authoring a skill [#authoring-a-skill]
A skill file can open with YAML frontmatter (`name` and `description`); if you leave it out, the filename and the first line stand in:
```markdown
---
name: gh-issues
description: Create and manage GitHub issues with the gh CLI.
---
# Managing GitHub issues with `gh`
...full instructions...
```
Paths in `skills:` are relative to the agent file. Write a skill the way you'd write a runbook for a new hire: the commands that work, the flags that matter, the failure modes and what to do about them.
## Progressive disclosure [#progressive-disclosure]
A skill stays out of the model's context until it's needed. The system prompt carries one line per skill (its name and description), and the agent reads the full document with the `read_skill` tool when the task calls for it. This means that an agent with ten skills spends ten lines of context on them until one is actually read.
## First-party skills [#first-party-skills]
The [`skills/`](https://github.com/loopedautomation/agent-framework/tree/main/skills) directory holds the skills maintained with the framework:
* [`gh-issues`](https://github.com/loopedautomation/agent-framework/blob/main/skills/gh-issues.md) - create and manage GitHub issues with the `gh` CLI. The [gh-issues-cli example](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-cli) uses it.
* [`looped-authoring`](https://github.com/loopedautomation/agent-framework/blob/main/skills/looped-authoring.md) - scaffold and validate Looped agents with the `af` CLI. The [agent-zero example](https://github.com/loopedautomation/agent-framework/tree/main/examples/agent-zero), the agent that builds agents, uses it.
# Tools (/agent-framework/tools)
We kept the base toolset small: a handful of native tools, gated by permissions. Anything beyond that is something you add deliberately, either a [skill](/agent-framework/skills) plus a CLI or an MCP server. Every tool an agent carries is more attack surface, more context and one more way for a small model to get confused, so the framework ships with very little and lets you add the rest.
## Native tools [#native-tools]
Tools follow permissions. A native tool exists for the agent only when the [permissions](/agent-framework/permissions) block grants what it needs. This means that no unused tool schema takes up context, and there is nothing sitting there to misuse:
| Tool | Present when | Notes |
| -------------- | ------------------------------------ | ------------------------------------------------------------------------ |
| `current_time` | always | the only tool granted unconditionally |
| `run_bash` | `permissions.run` grants executables | statically checked per executable; output capped at 8k chars |
| `http_request` | `permissions.net` grants hosts | GET/POST/PUT/PATCH/DELETE/HEAD; 30s timeout; body capped at 8k chars |
| `read_file` | `permissions.read` grants paths | capped at 8k chars |
| `write_file` | `permissions.write` grants paths | creates parent directories |
| `read_skill` | `skills:` lists any | [progressive disclosure](/agent-framework/skills#progressive-disclosure) |
| `search_tools` | tool search is deferring | see below |
An agent with no `permissions:` block gets `current_time`, plus `read_skill` if it has skills. Nothing else.
## MCP servers [#mcp-servers]
Use an MCP server when a good one exists and is worth the context cost:
```yaml
tools:
mcp:
- name: github
command: ["docker", "run", "-i", "ghcr.io/github/github-mcp-server"] # stdio
env:
GITHUB_TOKEN: ${GITHUB_TOKEN} # scoped: the server sees only this
include: [create_issue, update_issue, search_issues]
- name: internal
url: https://mcp.internal.example.com/mcp # or HTTP
```
* Tools are namespaced `mcp__github__create_issue` in the loop and the audit trail.
* We strongly recommend `include:`. A 40-tool server puts 40 schemas into a small model's context; expose the three you actually need. `include:` is also the permission surface for MCP: a tool you didn't include does not exist for the agent.
* `readonly: true` on a server exposes only tools whose `readOnlyHint` annotation marks them read-only, which is a good fit when the job only reads. The hint is self-reported by the server, so treat this as a guard against wiring write tools into a read-only job; the trust decision is still whether to declare the server at all.
* Every MCP call is recorded in the audit trail with the tool name and whether it succeeded, alongside the run's permission decisions.
* Each server sees only its own `env:` block (values may be `${VAR}` references); the agent's own environment stays private.
* Results are truncated at 8k chars; servers connect at startup and close on shutdown.
The [gh-issues-mcp example](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-mcp) is a complete deployment of this block: the Dockerfile installs the GitHub MCP server binary and the agent file exposes five of its tools. Its sibling [gh-issues-cli](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-cli) does the same job with the `gh` CLI and a skill, so the two read as a side-by-side comparison.
## Tool search [#tool-search]
`include:` keeps context lean by hand; tool search does the same thing automatically. When the total toolset grows past 10, MCP tool schemas stay out of context entirely. The model gets a single `search_tools` schema, and it activates the tools the task needs while the run is underway:
```yaml
tools:
search: auto # auto (default) | on | off
```
* `auto` - defer MCP tools when the agent carries more than 10 tools in total; below that, everything loads normally.
* `on` / `off` - always or never defer, regardless of count.
A search is a keyword match against tool names and descriptions; the top matches (up to five, with a relevance cutoff) become callable for the rest of the run. Native and skill tools always load, since they are small and framework-owned. `include:` and `search` compose: `include:` filters the server down to what the agent should ever see, and search decides when it sees it.
## Custom tools [#custom-tools]
`tools.custom` (TypeScript tool modules) is planned and hasn't landed yet; the config loader rejects the key loudly, so a config that relies on it fails immediately ([#12](https://github.com/loopedautomation/agent-framework/issues/12)). In the meantime, give the agent a CLI and a [skill](/agent-framework/skills).
# Permissions (/agent-framework/permissions)
A service agent runs at 3am, triggered by a webhook, on a machine nobody is watching. There is no one to ask "may I run this?", so the question has to be answered before the agent starts. That is what the `permissions:` block is for: you declare once, in config, which hosts, which executables and which paths the agent is allowed to touch, and everything else is denied. A denied action goes back to the agent as context for its next turn. This page is the reference; the reasoning behind the design is in [The permission model](/agent-framework/permission-model).
## Deny by default [#deny-by-default]
An agent with no `permissions:` block can touch nothing.
```yaml
permissions:
net: [api.github.com, "*.internal.example.com"] # hosts http_request may reach
run: [gh, echo] # executables run_bash may spawn
read: [/workspace] # readable path prefixes
write: [/workspace/out] # writable path prefixes
```
* **`net`** - hosts, matched exactly; `*.example.com` matches subdomains, and the apex needs its own entry.
* **`run`** - executables, matched by basename.
* **`read` / `write`** - path prefixes. Paths are normalized before the check, so `..` traversal cannot step outside the allowlist.
Tools follow permissions: `run_bash` only exists for the agent if `run:` grants something, `http_request` only if `net:` does and `read_file`/`write_file` only if `read:`/`write:` do. This means that no unused tool schema takes up context. The full toolset is in [Tools](/agent-framework/tools).
## Denials are tool results [#denials-are-tool-results]
A denied action is an ordinary tool result. The model sees `permission denied: run access to "curl" is not in the agent's permissions.run allowlist` and works with that on its next turn: it asks differently, stays within its grants or reports what it couldn't do. Every decision, allowed and denied, lands in the [audit trail](/agent-framework/docker-run#persistence-the-data-volume).
## Static analysis of shell commands [#static-analysis-of-shell-commands]
`run_bash` does not trust the shell: it extracts every executable from pipes and chains and checks each one against `run:`. Command substitution (`$(...)`, backticks, `<(...)`) is rejected outright, because there is no way to check it statically before it runs.
The check reads the executable at the head of each segment; it can't see into the arguments. That is fine for ordinary tools, and it means you should keep programs that run *other* programs off the allowlist. Granting any of these hands over everything:
* shells: `run: [bash]` lets `bash -c ''` through, since the inner command travels as an opaque string
* interpreters: `python -c`, `node -e`, `deno run`
* wrappers and exec flags: `env`, `xargs`, `timeout`, `find -exec`
Grant the specific CLIs the agent's job needs (`gh`, `grep`) and let the [container](#the-layers) be the backstop. The MCP examples that launch a server via `bash -c` are unaffected: that spawn comes from your config at startup and never passes through `run_bash`.
## Scoped environments [#scoped-environments]
Subprocesses receive only the env vars the config's `env:` block grants, plus `PATH`/`HOME`; the agent process keeps its own ambient environment to itself. The same goes for MCP servers: each one sees only its own `env:` block.
## Secrets [#secrets]
The config names an environment variable; the value stays out of the file:
```yaml
env:
GITHUB_TOKEN: ${GITHUB_TOKEN}
```
The value resolves from the process environment first, then from `/run/secrets/` (Docker Compose file secrets). A missing reference fails at startup, before any event is handled. Secrets are injected into tools server side and never enter the model's context; the model can use `GITHUB_TOKEN` without ever seeing it.
## The layers [#the-layers]
Enforcement is layered: the app-level engine described above runs inside a runtime sandbox, which runs inside a container.
1. **The Deno sandbox.** The config compiles to Deno permission flags; `af flags agent.yaml` prints them, e.g. `--allow-net=api.github.com --allow-run=gh`. In the [base image](/agent-framework/docker-run#what-the-base-image-gives-you), reads are scoped to `/agent`, `/skills`, `/data` and `/run/secrets`; writes to `/data`; subprocess spawning to `bash`, which the permission engine then gates per executable.
2. **The container.** This is the unit of isolation; the compose examples add `read_only: true` and a tmpfs.
Two honest notes on where the layers actually sit:
* The Deno layer allows all *network* egress in the container (`--allow-net`). Per-host enforcement happens in the app-level permission engine, and the container's egress policy is layer 2; restrict it with your network setup where it matters.
* `bash` subprocesses escape the Deno sandbox by design; the container boundary is what contains them. That is why there is no "run on the host" mode.
# Memory (/agent-framework/memory)
The `memory:` block controls two independent things: whether a conversation's history replays on the next message in that thread, and whether the agent can save facts that outlive any single conversation.
```yaml
memory:
scope: thread # default: none
persistent: true # default: false
```
Both default off. An agent with no `memory:` block starts every run with a blank slate — no history, no remembered facts — which is the right choice for a stateless webhook handler that shouldn't accumulate anything between calls.
## Thread history: `scope` [#thread-history-scope]
`scope: thread` persists the message transcript per *conversation key* — the chat channel or thread (Discord, Slack, Telegram), the webhook caller's `conversation_id`, or the REPL session. On the next event in the same conversation, the full prior transcript loads back in, so follow-ups work ("make it weekly instead" refers to what was just discussed). `scope: none` (the default) starts every run fresh, even within what a human would call the same conversation.
This is the entire transcript, replayed verbatim — expensive in context, but complete. It answers "what did we just say to each other," not "what do you know about me."
## Persistent memory: `persistent` [#persistent-memory-persistent]
`persistent: true` gives the agent four tools, backed by its own SQLite file:
| Tool | Effect |
| --------------- | ------------------------------------------------------------------------------ |
| `remember` | Save or update a fact under a key. Overwrites any existing value for that key. |
| `recall` | Read one fact back by key. |
| `list_memories` | List every key and value currently held. |
| `forget` | Delete a fact by key. |
Unlike thread history, these facts are keyed by nothing but the agent itself — they're visible from *every* conversation key, and they survive a run that starts with `scope: none`. This is where an agent puts a user's stated preference ("always deploy to us-east"), a fact it was told once and shouldn't need repeating ("the on-call rotation is in #incidents"), or a note to its future self about long-running work ("waiting on PR #204 to merge before continuing the migration"). Thread history can't do this: it's scoped to one conversation key, and it's a full transcript rather than a distilled fact.
The agent decides what's worth remembering — there's no automatic extraction from the conversation. A `purpose` that expects the agent to retain user preferences should say so explicitly, the same way it would spell out any other expected behavior.
### What the model sees [#what-the-model-sees]
Reading every remembered fact into every system prompt would burn context as memories accumulate, so persistent memory follows the same progressive-disclosure shape as [skills](/agent-framework/skills#progressive-disclosure): the system prompt carries only the *keys* currently held —
```
You have persistent memory — facts and preferences that survive across
conversations and restarts. Use recall to read one, list_memories to browse,
remember to save or update one, forget to delete one. Keys you already have:
- deploy_region
- oncall_channel
```
— and the agent spends a `recall` or `list_memories` call to pull the value into context only when a turn actually needs it. An agent with fifty memories costs fifty short lines until it reads one.
### Where it lives, and its boundaries [#where-it-lives-and-its-boundaries]
Memories live in the `memories` table of the agent's own SQLite file, alongside sessions, runs, audit and identity — the same file described in [Persistence: the data volume](/agent-framework/docker-run#persistence-the-data-volume). This keeps memory agent-local, consistent with one agent doing one job: there is no mechanism for one agent to read another's memories, and a fresh data volume clears memory exactly the way it clears identity and history.
Every `remember` and `forget` call lands in the [audit trail](/agent-framework/docker-run#persistence-the-data-volume) as a `memory` event (`{ action: "remember" | "forget", key }`), visible at `GET /audit` alongside permission decisions. `recall` and `list_memories` are read-only and aren't audited, the same way an allowed `read_file` isn't.
## Combining both [#combining-both]
`scope` and `persistent` compose freely — they answer different questions:
```yaml
memory:
scope: thread # replay this conversation's transcript
persistent: true # and carry facts across every conversation
```
A support-bot agent might run `scope: thread` alone (each ticket thread needs its own context, nothing more), while a personal-assistant agent typically wants both: thread history for the back-and-forth of the current request, persistent memory for "she prefers window seats" to still be true next month, in a different channel.
# Models (/agent-framework/models)
Every agent names its model in the required `model:` block; there is no fleet-wide default. The `provider` field is a **dialect**: two dialects cover effectively every hosted and local endpoint, and swapping providers is a one-line change. The short version lives in [Agent Config](/agent-framework/agent-file#model); this page covers the details.
```yaml
model:
provider: openai-compatible # or: anthropic
id: gpt-5.4-mini
```
## The two dialects [#the-two-dialects]
| | `openai-compatible` | `anthropic` |
| ------------------- | ------------------------------------------------------------------------------------- | --------------------------------- |
| Speaks to | OpenAI, Ollama, vLLM, LiteLLM, OpenRouter — anything serving the chat-completions API | The native Anthropic Messages API |
| Default endpoint | `https://api.openai.com/v1` | `https://api.anthropic.com` |
| Default key env var | `OPENAI_API_KEY` | `ANTHROPIC_API_KEY` |
| `base_url` | Any compatible endpoint — this is how local models work | Anthropic-compatible proxies |
`id` is the plain model identifier the endpoint expects — `gpt-5.4-mini`, `claude-sonnet-5`, `llama3.1`. There is no combined `provider/model` string syntax; the two fields stay separate, which is what makes `base_url` proxies transparent.
## API keys [#api-keys]
The config names an environment variable; the key itself stays out of the file. At startup the runtime reads the key from the environment variable named by `api_key_env`, defaulting to `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` per provider:
```yaml
model:
provider: openai-compatible
id: gpt-5.4-mini
api_key_env: OPENROUTER_API_KEY # optional: which env var holds the key
```
Supply the variable the way your deployment supplies env: `export` locally, `--env-file .env` with `docker run`, `env_file:` in compose. One subtlety: the provider key is read straight from the process environment — unlike `${VAR}` references in the [`env:` block](/agent-framework/agent-file#env), it does not fall back to `/run/secrets/` files.
A missing key fails at startup, before any event is handled:
```
missing API key: set OPENAI_API_KEY (or point model.api_key_env at the right env var)
```
The one exception: `openai-compatible` with an explicit `base_url` needs no key, because local models usually don't have one.
## Local models [#local-models]
```yaml
model:
provider: openai-compatible
id: llama3.1
base_url: http://localhost:11434/v1 # Ollama
```
`base_url` points the dialect at any compatible endpoint — Ollama, vLLM, a LiteLLM proxy — and with it set, no API key is required. `af init --provider local` scaffolds exactly this shape.
When the agent runs in a container, remember `localhost` is the container itself: use `http://host.docker.internal:11434/v1` to reach a model server on the host (on Linux, add `--add-host=host.docker.internal:host-gateway`).
## The small model [#the-small-model]
```yaml
model:
provider: openai-compatible
id: gpt-5.4-mini
small: gpt-5.4-nano
```
`small` routes the framework's cheap internal calls — today, the [naming step](/agent-framework/agent-file#identity-handle-description--and-the-name) on first boot — to a smaller model. It defaults to the main `id`; set it to a small model and those calls cost close to nothing.
## When the provider fails [#when-the-provider-fails]
Transient failures retry themselves: rate limits (HTTP 429), overload (5xx), and network errors get up to three attempts with exponential backoff (500 ms, then 1 s). Auth failures (401/403) and malformed requests (other 4xx) fail immediately — retrying wouldn't change the answer.
A call that still fails ends the run with status `error_provider` and a one-line reason. The *run* fails; the *service* stays up, waiting for the next event — statuses are in the [limits table](/agent-framework/agent-file#limits).
**`fallbacks`** declares model ids to try in order when the primary fails. The schema accepts and validates the field today, but the runtime chain hasn't landed yet — until it does, a failed primary ends the run `error_provider` regardless of the list.
## What is deliberately not configurable [#what-is-deliberately-not-configurable]
There are no `temperature` or max-output-token fields; requests use the provider's defaults (the `anthropic` dialect caps output at 4096 tokens per call). If you need one of these controls, put a rewriting proxy such as LiteLLM behind `base_url`. The exhaustive field list is the [JSON Schema](https://github.com/loopedautomation/agent-framework/blob/main/schema/agent.json), enforced [in your editor](/agent-framework/agent-file#editor-support) as you type.