FastAgent keeps behavior and deployment choices separate:
- agent behavior lives in
persona.md(identity),skills/,tools/, andAGENTS.mdproject context, - deployment choices live in
fastagent.config.*, CLI flags, and environment variables, - secrets live in
<agent dir>/.secrets/(.env+ the project-levelauth.json) or provider env vars.
Config file
An agent may contain exactly one config file:
fastagent.config.ts
fastagent.config.js
fastagent.config.mjsExample:
import { defineConfig } from "@fastagent-sh/fastagent";
export default defineConfig({
model: "openai-codex/gpt-5.5",
http: { port: 8787 },
});Supported keys:
| Key | Meaning |
|---|---|
model |
Default model spec, in provider/modelId form. |
thinkingLevel |
Reasoning effort for the model, on pi’s scale: off | minimal | low | medium | high | xhigh | max. Default: medium — pinned by fastagent to match the pi TUI’s default (authors vibe at medium, so serving must match; the pin also means an upstream default change cannot silently alter deployments). Levels a model doesn’t support are clamped by the engine. |
tools |
Extra programmatic tools appended after the pi coding tools. Most users should prefer tools/ discovery. |
http.port |
Default port for dev / start. |
http.host |
Bind address for dev / start. Unset (or 0.0.0.0) binds all interfaces — what containers need. --bind overrides it; prefer the flag for a local-only bind, since this value travels into a deployed image (see Bind address). |
selfSchedule |
Mount the built-in wake tool so the agent can schedule its own follow-up turns (self-scheduling). Off by default — an autonomy capability, opt in when you want it; only active on the serving path (dev/start or createAgentService). Resident hosts poll locally; AgentCore ingress uses external wake alarms. |
sessionControl |
Serve the session control plane at /control/* (a session’s state/entries/live events, its actions — steer/abort/compact — its properties, and the deployment’s session list) for remote consumers — a Web panel, a desktop app, fastagent attach. Off by default (it is a remote-control surface). When on, dev/start mint a per-boot bearer token into <stateRoot>/control.json; the serve binds all interfaces by default, so the routes are LAN-reachable with the token as the only protection — bind loopback (--bind 127.0.0.1 — not http.host, which travels into a deployed image), firewall the port, or wrap it. On a deployed box (fastagent deploy) the routes ride the public host URL, so the token comes from outside instead: set FASTAGENT_CONTROL_TOKEN as a deploy secret (deploy lists it and warns) and the serve uses that value rather than minting one nothing outside can read. |
deploy.secrets |
Extra secret env-var names the deployed agent needs (e.g. ["GH_TOKEN"]). deploy lists them in the runbook and, under --run, carries each value from your local env to the host secret store; a missing value gates the run. |
deploy.apt |
Extra apt packages baked into the generated image (["git", "ripgrep"] — Debian default repos). For a package needing a custom apt repo (e.g. gh) or a different base image, provide your own Dockerfile — deploy keeps an existing one (and warns that deploy.apt isn’t applied to a hand-written Dockerfile). A Dockerfile fastagent generated that later drifts from the current config (a changed deploy.apt, a new lockfile) is kept but flagged stale; --force regenerates it. |
Unknown keys fail at startup. This catches typos such as modle instead of silently degrading to defaults.
The generated .dockerignore keeps .git so an agent can inspect history and synchronize approved changes. Add a .git exclusion if you do not need it, and verify the selected host’s packing behavior. See what deploy bakes.
Model selection
Model specs are strings like:
provider/modelIdList available specs:
fastagent models
fastagent models gptPrecedence:
CLI --model > FASTAGENT_MODEL > fastagent.config.* modelWith none of these set, a serving command (dev / start / invoke) run in a terminal prompts you
to pick from the full model catalog (ready providers first, annotated with the credential source;
a pick that needs auth runs login inline), then writes the choice back to the config. Non-interactive runs (CI, a container) skip the prompt and fail with a clear missing model
error instead — set one of the sources above.
Examples:
fastagent dev --model openai-codex/gpt-5.5
FASTAGENT_MODEL=openai-codex/gpt-5.5 fastagent startCustom model endpoints
To run against something the built-in catalog does not know — a self-hosted model (vLLM, SGLang,
Ollama, LM Studio) or your own gateway/proxy — declare it in models.json next to the config
file, in the agent dir:
my-agent/
├── fastagent.config.ts
├── models.json ← custom endpoints
└── persona.mdThe file’s existence is the switch; there is no config key for it. An endpoint needs a URL, an API shape, a key, and the model ids it serves — everything else has a default:
{
"providers": {
"mygw": {
"baseUrl": "http://vllm.internal:8000/v1",
"api": "openai-completions",
"apiKey": "$MYGW_API_KEY",
"models": [{ "id": "deepseek-v3", "contextWindow": 65536 }]
}
}
}The provider id joins the model id into a normal spec, usable everywhere a spec is:
export default defineConfig({
model: "mygw/deepseek-v3",
});Custom endpoints are additive — built-in providers stay available alongside them.
Keys stay out of the file
apiKey (and any headers value) resolves at request time: "$MYGW_API_KEY" reads an environment
variable, "!cmd" runs a command and uses its stdout, anything else is a literal. Prefer the env
form — the file then stays safe to commit and to bake into an image.
deploy recognizes the variable backing the selected model and carries its value to the host like any
provider key, listing it in the runbook and refusing --run when it has no local value. You do not
need to declare it. deploy.secrets remains for the variables deploy cannot infer — a key used only
in headers, or a value assembled from several variables ("${A}_${B}"):
export default defineConfig({
model: "mygw/deepseek-v3",
deploy: { secrets: ["MYGW_PORTKEY_KEY"] },
});A key written INTO models.json (a literal, or a !command resolved on the host) travels with the
file itself, so there is nothing to carry — and deploy does not ask for one.
Routing a built-in provider through a proxy
Give an existing provider a new baseUrl and nothing else. Its full model list, pricing metadata and
compatibility flags are kept, and existing OAuth / API-key auth keeps working:
{
"providers": {
"deepseek": { "baseUrl": "https://llm-proxy.internal/v1" }
}
}Compatibility flags
OpenAI-compatible servers differ in the details. compat (per provider, or per model to override)
carries the switches — e.g. supportsDeveloperRole: false for servers that reject the developer
role, or thinkingFormat for reasoning models behind a chat template.
Useful native compat settings:
| Setting | Use |
|---|---|
vllmPriority |
OpenAI Completions: sends vLLM’s request priority. Lower numbers run earlier; requires the server’s --scheduling-policy priority. |
supportsMaxOutputTokens: false |
OpenAI Responses: omits max_output_tokens for gateways that reject it. |
supportsMidConvoEffort: true |
Anthropic Messages: enables per-turn effort and signed-thinking binding controls. Enable only for a verified Claude model and faithful transport. Pi persists the response effort in the session. |
These belong in the definition-local models.json; fastagent adds no parallel settings.
The schema is pi’s own; its full field reference, including every compat flag and per-model
override, is in pi’s docs/models.md (@earendil-works/pi-coding-agent). Two things are specific to
FastAgent:
| Behavior | Why |
|---|---|
pi’s machine-global ~/.pi/agent/models.json is not read |
Deployment behavior must come from the bundled definition, not the builder machine — a globally-defined endpoint would work locally and vanish on deploy. |
A malformed models.json fails startup |
Upstream degrades silently to the built-ins; FastAgent surfaces the parse error instead of letting it resurface later as unknown model. |
fastagent models lists the built-in catalog only — it answers “what does FastAgent support”, not
“what does this agent use”. To confirm what an agent resolved, run fastagent info.
Auth and secrets
FastAgent resolves model credentials through the model provider layer. Common options:
| Source | Use case |
|---|---|
fastagent login |
Stores OAuth/API-key credentials in the project-level <agent dir>/.secrets/auth.json (override: --auth-path / FASTAGENT_AUTH_PATH, a leading ~ is expanded; run outside any agent for the global ~/.fastagent/.secrets/auth.json — announced on stderr). |
| Provider env vars | Good for servers and CI, e.g. ANTHROPIC_API_KEY or OPENAI_API_KEY. |
Agent .env |
Local development secrets at <agent dir>/.secrets/.env, loaded by CLI commands. Excluded by the .secrets/.gitignore that init scaffolds. |
Do not commit .env or provider credentials.
Run fastagent info or fastagent dev to see the resolved auth source for the selected provider.
Ports
Port precedence for dev:
--port > fastagent.config.* http.port > 8787Port precedence for start:
--port > PORT > fastagent.config.* http.port > 8787Use PORT in hosted environments that inject a port.
Bind address
--bind > fastagent.config.* http.host > all interfaceslocalhost is accepted and resolved to 127.0.0.1 as it is read, so what binds, what the startup
lines print and what control.json records are the same address — a name would leave that to
dns.lookup on one side and to the client’s resolver on the other, which can disagree.
All interfaces is the default because containers require it. A desktop app driving a local agent wants
the opposite: --bind 127.0.0.1 keeps the port — /control/* with it — unreachable from the LAN.
<stateRoot>/control.json records the address a client should dial, so clients read it rather than
assume one.
FastAgent does not provide a security boundary, and a default cannot be one. The built-in
POST /invoke has no authentication; author-written tools/ can import anything; a WebSocket or
Socket-Mode channel dials OUT, so no bind address constrains who can message the agent. Whoever can
reach an agent can use everything it mounts. Put it behind an application, a gateway, or a network
you control — that is where the boundary belongs, and a narrower default here would substitute a
feeling for one.
Two edges: --tunnel reaches the serve by dialing localhost, so a bind that name never resolves to
(--bind 192.168.1.5, or even --bind 127.0.0.2) is refused with it; and http.host travels into a deployed image, where any non-wildcard bind
breaks the container (unreachable, or unable to bind at all) — deploy warns and gates --run, so keep
the local-only choice on --bind.
Machinery: .state/ and .secrets/
The agent carries two fastagent-managed machinery dirs, split by deploy lifecycle:
<agent dir>/.state/— mutable machine state: sessions, channel state (channels/<kind>/), schedule state. Precious, single-process, must survive a redeploy → a container points it at a volume.<agent dir>/.secrets/— secrets: the agent’s.envand the project-levelauth.json. The scaffolded.secrets/.gitignorekeeps credential contents uncommitted. Deploy excludes those contents while shipping the tracked.env.exampleand.gitignorescaffolds, so no credential is baked into an image. A deployed box gets values through the host’s secret store, and its seeded (possibly rotated)auth.jsonalso lives on the volume so refresh survives restarts.
For deployments, point both at durable storage:
FASTAGENT_STATE_DIR=/data/.state FASTAGENT_SECRETS_DIR=/data/.secrets fastagent startThe finer knobs still override their specific path on top:
state root: FASTAGENT_STATE_DIR > <agent dir>/.state
secrets: FASTAGENT_SECRETS_DIR > <agent dir>/.secrets
sessions: --sessions-dir > FASTAGENT_SESSIONS_DIR > <state root>/sessions
auth: --auth-path > FASTAGENT_AUTH_PATH > <secrets>/auth.jsonA leading ~ in any of these is expanded to your home dir.
Wherever auth.json lands, its directory is managed as a secrets directory: every credential write
(including an OAuth refresh mid-run) re-applies 0700 to it, and where the process cannot chmod it
the write fails rather than leaving the credential readable. Name a directory this process owns.
FASTAGENT_SECRETS_DIR moves both the agent’s .env and auth.json. The .env’s own location
resolves from the real environment — a FASTAGENT_SECRETS_DIR set inside .env still relocates
auth.json but cannot move the file it is read from. The committable .env.example template always
stays at <agent dir>/.secrets/.env.example.
Code inputs must be real files
tools/, channels/, and schedules/ are code the agent imports. Each directory must stay inside
the agent directory, and each entry in it must be a real file. A symlink is skipped with a warning
(fastagent info, dev, and start all say so) — it is never followed.
This is deliberate, not a gap. The containment check guards the directory; nothing guards the
entries inside it, so following a link would import code from anywhere on the machine past the check
that exists to prevent exactly that. It also would not survive deployment: deploy bakes the
workspace into an image, and a link pointing outside it resolves to nothing there — an agent that
works locally and is silently missing a tool in production.
To share code between agents, publish it as a package and import it, or copy the file.
Tools
There are two ways to add tools:
- Files under
tools/— recommended for agent authors. config.tools— programmatic injection for advanced embedding/config use.
tools/ files are auto-discovered. The filename is the tool name:
tools/lookup-order.ts -> lookup-orderEvery directory agent mounts pi’s complete coding set: read, grep, find, ls, bash, edit,
and write. These are basic agent capabilities, not a security policy. read also opens model-visible
skills and downloaded channel attachments.
If a deployment needs isolation, sandbox the whole agent process and restrict what that sandbox can reach. A tool allowlist would cover only pi’s built-ins while authored tools and channel code remain ordinary executable code, so it cannot provide that boundary.
config.tools and discovered tools/ are appended after the coding tools. Name collisions are
reported and the existing coding tool wins. Conditional built-ins remain independent: search_tools
mounts when a deferred tool needs it, and wake remains controlled by selfSchedule on the serving
path. Run fastagent info --json to inspect the complete mounted surface.
Reusable packages do not need a separate plugin contract: export ordinary FastagentTool[] and mount them explicitly:
import { integrationTools } from "@acme/fastagent-tools";
export default defineConfig({
tools: integrationTools({ apiKey: process.env.ACME_API_KEY! }),
});Package tools receive the same ToolContext as definition-local defineTool tools, including the
optional read-only sessionManager during serving/chat turns.
Extensions
Extension modules under extensions/ travel with the definition. They run in fastagent chat.
They are not loaded when serving (dev, start, channels, a container) — see the split below,
which is a limitation of pi’s extension runtime rather than a decision about your agent.
Two discovery shapes, matching pi:
extensions/notify.ts -> discovered
extensions/audit/index.ts -> discoveredpi’s third shape — a subdirectory whose package.json declares a pi field — is not supported;
such a directory is warned about rather than skipped in silence. A symlinked entry is refused: an
extension reached through a link out of the definition resolves on your machine and is missing in
the container.
Only the definition’s own extensions/ are considered. The machine’s ~/.pi extensions are
deliberately not — a served agent must not depend on the authoring machine’s setup.
Why serving does not run them
pi’s extension machinery is built for one process serving one session, which is what a terminal
is. Its own source calls the object holding a session’s actions “the shared runtime”, and every
AgentSession overwrites it on construction:
// Copy actions into the shared runtime (all extension APIs reference this)
this.runtime.sendMessage = actions.sendMessage;
this.runtime.appendEntry = actions.appendEntry;Serving is the opposite shape: one process, many concurrent turns, belonging to conversations that
have nothing to do with each other. With two turns in flight, the second one to start redirects
those actions to itself — so an extension calling pi.sendMessage() during the first turn can
deliver into the other person’s conversation. The same sharing applies to the extension module
itself, and to session_start / session_shutdown, which stop being a matched pair once several
sessions share one instance.
That is a silent correctness failure, and a silently wrong answer is worse than a missing feature. So serving does not load them, and warns at startup when a definition ships some.
This is fixable upstream, and narrowly: pi already has an uncached loader path that builds a fresh
module per call (jiti with moduleCache: false) and takes the runtime as an argument, which is
exactly per-session isolation. That function is not currently exported. When it is, serving can run
extensions with the same guarantees chat has today.
serving (dev, start, channels) |
chat |
|
|---|---|---|
| discovery, and its refusals | runs | runs |
| tools it registers | not mounted | offered to the model |
| event and lifecycle handlers | not run | run |
| commands it registers | not executable | executable |
select / confirm / input |
— | shown to you |
If your extension’s value is a slash command or a dialog, chat was always its home. If it
registers model-callable tools you need while served, write them as tools/ — that is the path
built for serving, and it is concurrency-safe.
When the repo already owns tools/ or channels/
Nothing to do — the agent lives in ./fastagent/, so FastAgent scans the agent’s own directories,
never the workspace’s names. fastagent.config.* identifies the agent directory; fastagent/ is its
default name. Within the agent, a broken tool is reported and skipped, while
a broken declared channel fails serving — an inbound endpoint must not silently disappear. If you want
programmatic tools outside the agent, declare them with config.tools.
More than one agent
Several sibling agent directories can work on the same workspace:
fastagent init . --agent-dir reviewer
fastagent init . --agent-dir releaser
FASTAGENT_AGENT=reviewer fastagent dev .
FASTAGENT_AGENT=releaser fastagent deploy fly .Each has its own config, persona, skills, tools, channels, schedules, .state/, and .secrets/.
With one agent, selection is automatic. With several, fastagent/ is the default if present;
otherwise set FASTAGENT_AGENT in the shell or .envrc. It is read before the agent’s .env.
The workspace is always the directory passed to the command. fastagent dev . above operates on
the shared project; fastagent dev reviewer operates on reviewer/ itself. To give agents separate
workspaces instead, use fastagent init reviewer and fastagent init releaser, which create
reviewer/fastagent/ and releaser/fastagent/ respectively. A config at the workspace root takes
precedence over child agents, so init refuses placements that would hide another definition.
Share code through packages or real files, not symlinks in code-input directories. See Code inputs must be real files.
Channels
Channels are not configured in fastagent.config.*. A channel needs glue code, so its file is the
enable switch: .ts / .js / .mjs files under channels/ are enabled; rename one to
<name>.ts.disabled to disable it without introducing a second config source.
channels/github.ts
channels/telegram.tsSee Channels.
Logging
Log verbosity is an environment knob, not a config key. FASTAGENT_LOG_LEVEL (debug | info | warn | error) overrides the per-posture default: dev defaults to debug, start to info. Per-turn traces log at debug, so start keeps end-user content out of production logs unless you opt into debug.
FASTAGENT_LOG_LEVEL=debug fastagent startThe value is read when a line is logged, so setting it in the agent’s .secrets/.env also takes effect locally (a real environment variable still wins over the file). .secrets/ never enters a deploy image, so on a deployed agent set the variable on the host instead.
What is deliberately not config
The following are library API injection points rather than config keys:
- custom session stores,
- custom execution environments (a complete sandbox adapter remains future work),
- distributed leases,
- base prompt overrides.
Use the library API in Embedding when you need those ports.
Custom model providers are the exception that proves the rule: a declarative endpoint is definition
data, so it lives in the agent’s own models.json. A provider that needs
CODE — minting a token per request, say — is still an injection point (providers, see
Embedding).

