self
\[SYS_EXT\] You are fir. Load this whenever a request is about fir itself — what it can do, how it works, or adding/changing any part of its configuration (models, providers, credentials, skills, extensions, MCP servers, settings) — instead of guessing from config files.
- License 许可
- MIT
- 所属仓库
- kfet/fir
- 最近更新
- 2026-11-13
- Stars
- ★ 1
- Forks
- 0
01 前言 Front Matter
- name
- self
- builtin
- true
- override
- true
- description
- \[SYS_EXT\] You are fir. Load this whenever a request is about fir itself — what it can do, how it works, or adding/changing any part of its configuration (models, providers, credentials, skills, extensions, MCP servers, settings) — instead of guessing from config files.
02 资源文件 1
- 参考文档mcp-config-contract.md480 B
03 SKILL.md 正文
fir — AI Coding Agent
You are fir. fir is a fast, portable AI coding agent. Single Go binary, no runtime dependencies. <2>0 MB static binary.
For detailed CLI flags, run fir --help. For interactive commands and keyboard shortcuts, use /help inside fir — it toggles a sticky, collapsible overlay above the editor (run /help again, or Ctrl+L, to dismiss it).
Logging & verbosity
-venables Debug logging to<agent-dir>/debug.log(same as--debug;<agent-dir>defaults to~/.config/fir).-vvenables Trace logging — per micro-op events such as cache-prefix invalidations and MCP per-event chatter.-vvvand beyond clamp to-vvwith a warning.-V(or--version) prints the version. The old-vshort alias for--versionis gone; lonefir -vprints a migration note to stderr.FIR_LOG_LEVEL=info|debug|trace(or a raw slog numeric level) sets the level via env; wins overFIR_DEBUG=1. CLI-v/-vvwin over both.debug.logis rotated in place when it grows past a size cap. Tune viasettings.json"debugLog"(global or project):maxSizeMB(default 100),keep(default 20 compressed backups),compress(default true),checkEveryWrites(default 1000),checkEverySeconds(default 30). Backups are standard gzip (debug.log.1.gz,.2.gz, …) readable bygunzip/zcat. Rotation is cross-process safe via an advisory flock ondebug.log.lock.
Installing fir
- Homebrew (macOS, Linux):
brew install kfet/ai/fir(auto-tapskfet/homebrew-ai; the formula is regenerated by GoReleaser on each release and pulls binaries from thekfet/fir-distmirror, so no GitHub auth is required). To pin or roll back to a specific minor channel:brew install kfet/ai/fir@0.29— pinned and rolling formulas both providebin/fir, so switching between them usesbrew unlink fir/brew link --overwrite fir@0.29(the rollback flow). The tap keeps the 10 most-recent minors. - Install script (macOS, Linux, Raspberry Pi):
curl -fsSL https://raw.githubusercontent.com/kfet/fir-dist/main/install.sh | sh - Self-update:
fir update(pulls fromkfet/fir-dist). - Check only:
fir update -checkreports the running and latest versions and exits 3 when an update is available (0 when up to date, 1 on error), so a fleet script can branch on the status. It never downloads or replaces anything. Resolution goes throughgithub.com/kfet/distkit, the family's distribution library, which is wired in DORMANT: it resolves and reports, while fir's own stdlib updater (pkg/update/release.go) performs the actual swap — it checks the download against the release'schecksums.txt(catching corrupt or truncated downloads) and refuses to install on a mismatch or missing entry. The cached 24h background check runs both resolvers and records any disagreement in<agentDir>/update-check.json(distkit_version,distkit_error), logging it at WARN;fir update -checkprints the last disagreement if there was one.
Shell completion
Bash and zsh completion ship with fir. Homebrew and the install script drop them automatically; for manual setup or go install builds:
fir completion bash > ~/.local/share/bash-completion/completions/fir
fir completion zsh > "${fpath[1]}/_fir" && compinit
The completion covers every flag and subcommand and dynamically completes --provider, --model, --extension, --skill, --session, and installed packages by shelling out to fir --list-models, fir extensions list, etc. A build-time test (cmd/fir/completion_test.go) parses args.go and fails CI if any new flag/subcommand is missing from the completion scripts.
Not to be confused with fir install <source>, which installs packages (skills/extensions/themes) into an existing fir install — see "External Packages" below.
Modes
- Interactive (default) — full TUI with markdown rendering, streaming, slash commands, and live plan visualization (
/planto toggle) - Print (
-p/--print) — non-interactive one-shot; process prompt and exit - ACP (
--mode acp) — Agent Client Protocol, JSON-RPC 2.0 over stdio for IDE integrations (e.g. Zed). Supports the unstablesession/list,session/resume,session/fork, andsession/releasemethods. To curb unbounded session/sidecar growth when a relay never releases sessions, fir auto-reaps in-memory ACP sessions idle longer than--acp-session-idle-ttl(default 90m — keep above the 1h prompt-cache TTL;0disables) — an idle session then holds zero extension sidecars. Every session (all modes) saves its state — model, thinking, name, cwd, mode,_meta, session-scoped/clientmcpServers— next to its transcript (<session>.jsonl.state.jsonl, 0600) and restores it on-c/--session//resume(seedocs/session-restore.md). In ACP thesessionIdis a handle bound to the current transcript, so the next prompt on a reapedsessionId— even after a fir restart — rebuilds the session in place (same ID).session/new/load/resumevalues override and are saved. A resume never waits for MCP servers: each is advertised from its saved tool schemas until it connects (calls to a down server return a tool error); a client replaces its servers withsession/load. Unknown-session requests (prompt/release/set_model/etc.) return the typed JSON-RPC error code-32001(session not found) so relays can transparently re-create the session. MCP data passthrough (capabilityagentCapabilities._meta["dev.fir.mcp/v1"]): MCP tool calls carry_meta["dev.fir.mcp/v1"]with the server, original tool name, tool definition_meta(e.g.ui.resourceUri), result_metaandstructuredContent; MCPresource/resource_linkresult blocks are kept as ACP resource blocks; extension methods_fir/mcp/resources/readand_fir/mcp/tools/callreuse the session's MCP connections, limited to tools in the session's active tool set (seedocs/acp-mcp-passthrough.md). fir does no UI work — clients render MCP Apps themselves. Mid-turn steer (capability_meta["dev.fir.steer/v1"]):_fir/session/steer{sessionId, prompt}queues content onto the running turn's steering queue (seen at the next tool boundary, turn not cancelled); with no running turn it returns-32002and queues nothing.mode: "record"with{question, answer, forkSessionId?}instead appends aside_querycustom transcript entry that is never sent to the LLM, with or without a running turn (seedocs/acp-steer.md).
Configuration Hierarchy
Settings merge in order (later wins):
- Global config —
<agent-dir>/settings.json(~/.config/firby default; override withFIR_AGENT_DIRorfir --agent-dir <dir>, with the CLI flag winning) - Project config —
.fir/settings.jsonin project root - CLI flags — override everything
All settings fields are optional. Project settings are merged on top of global settings field-by-field; nested objects merge recursively, arrays and primitives from the override win.
Global Directory (<agent-dir>/, default ~/.config/fir/)
| File | Purpose |
|---|---|
settings.json |
Default provider, model, thinking, compaction, retry, terminal, image settings |
models.json |
Add or override models per provider (custom ids, base URLs, headers, costs, sweScore) |
provider-usage.json |
Usage/auth warnings: enabled, warn_levels ([80,95]), stale_after_seconds (600), refresh_grace_minutes (15), login_max_age_days (0=off), burst_points (3), burst_window_seconds (600) — burst = account-wide 5h utilization jump read from response headers |
keybindings.json |
Custom key bindings for interactive mode |
sessions/ |
Saved conversation sessions |
skills/ |
User-level skills (shared across projects) |
extensions/ |
User-level extensions (shared across projects) |
packages/ |
Installed external packages (user scope) |
Project Directory (.fir/ in project root)
| Path | Purpose |
|---|---|
settings.json |
Project-level setting overrides |
keybindings.json |
Project-level keybinding overrides (merged on top of global) |
skills/ |
Project-specific skills (auto-discovered) |
extensions/ |
Project-specific extensions (auto-discovered) |
packages/ |
Installed external packages (project scope, via --local) |
mcp.json |
MCP server configuration (project-level) |
Project Context Files
fir reads AGENTS.md (or CLAUDE.md) from the working directory and its ancestors, automatically including them in the system prompt. This is the primary way to give fir project-specific instructions.
Authentication
Two methods:
- Environment variables — set
<PROVIDER>_API_KEY(e.g.ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY). See the full list below. - OAuth — use
/loginin interactive mode, orfir login <provider-id>from the shell. Supports Anthropic, Google Antigravity, OpenAI Codex, GitHub Copilot, and Poe. Credentials are persisted automatically. - New OAuth providers can be added as extensions
Login subcommand
fir login <provider-id> runs the OAuth flow for a provider from the shell (no interactive TUI required) and writes credentials to <agent-dir>/auth.json. Because OAuth providers are contributed by auth extensions, fir login loads extensions before running the flow — honouring the settings.json extensions allowlist, -e/--extension, -d/--disable-extension, and --no-extensions, exactly like the main CLI. fir login list (or bare fir login) lists every provider registered after extensions load. Equivalent to the long-standing --login <provider-id> flag; both paths share the same session-bootstrap code.
Multiple accounts per provider
A provider can hold several accounts at once. Running fir login <provider-id> a second time adds another account rather than evicting the first — e.g. a personal and a work Anthropic login coexist. Accounts are stored in auth.json under composite slot keys: the bare <provider> key is the default account, and extra accounts use <provider>#<accountId>. For Anthropic the account id is a human-readable slug built from the email and organization name (e.g. anthropic#me@example.com-acme-corp, falling back to uuids only when neither is available), and the selector label shows the display name (or email) plus the org — e.g. Ada Lovelace (Acme Corp). The same user logged into two different orgs therefore yields two distinct, recognisable accounts. Older fir builds reading the file still find the default account and ignore the #account keys.
Each account appears as its own labelled group in the model selector and --list-models (e.g. "Anthropic (Claude Pro/Max) (work@x.com)"), so switching accounts live is just selecting a model under that group — no env juggling, no restart. fir login list also prints the stored accounts. Remove a single account with fir logout <provider[#account]> (or fir logout list to see slots).
Keeping an OAuth login alive on an idle machine
fir auth refresh [provider-id | provider-id#account] performs the OAuth refresh grant and nothing else — no model, no tools, no inference, no quota. It exists because fir otherwise only refreshes a token as a side effect of running a turn, so a box that sits idle (a bot host, a rarely-used fleet machine) eventually lets its refresh token expire and needs an interactive browser re-login. Put it in cron:
0 4 * * * fir auth refresh
- Provider defaults to
anthropic; any registered OAuth provider id works. Nothing provider-specific is hardcoded — endpoints, client id and expiry normalisation come from the extension'sdeclare_oauth_providerspec andauth_post_exchangehook. - Every stored account slot of that provider is refreshed, default first. One slot failing does not stop the others, and the exit status is non-zero if any failed.
- Pass an account slot key (
fir auth refresh anthropic#work@x.com) to refresh just that one account. This is the sameprovider#accountform this command prints, and the onefir login listandfir logoutaccept, so a slot key pasted straight back in works. An unknown slot under a valid provider lists the provider's stored slot keys instead of refreshing anything. - Output is one greppable tab-separated line per slot:
<slot> <label> <outcome> expires=<RFC3339> in=<duration>, where outcome isrefreshed,fresh,skippedorfailed. - A credential more than an hour from expiry is left alone rather than needlessly rotated.
--within DURATIONmoves that guard;--forcerotates regardless.
Rotation is destructive — know this before you use --force. OAuth refresh tokens rotate on every grant, and Anthropic revokes the previous access token the instant the rotation happens — it does not survive to its stated expires. fir handles this: any process resolving a key re-reads auth.json when it has changed on disk, and the request path re-reads unconditionally before reporting an auth failure, so a session that is live across a rotation picks up the new token and retries rather than dying on a 401 OAuth access token has been revoked. Never hand-write auth.json or run a refresh grant outside fir — an out-of-tree writer racing fir over a rotating credential can replay a consumed refresh token and get the entire token family revoked.
Copying the agent dir does NOT isolate a
--forcetest.cp -a ~/.config/fir /tmp/firtest && FIR_AGENT_DIR=/tmp/firtest fir auth refresh --forcelooks safe and is not. The copy duplicates the file, but the refresh token inside it is the same credential upstream — forcing a grant on the copy rotates the real credential at the provider and revokes the access token every live fir session is holding. A copied agent dir is only safe for paths that spend no grant (a plainfir auth refreshthat reportsfresh,skipped, or an unknown-provider error). Genuinely isolated--forcetesting needs a separate login, not a file copy.
Secret storage (auth.storage, fir auth storage)
By default every credential (provider logins, API keys, MCP OAuth tokens) is plain text in auth.json (0600, flock-guarded). Set "auth": {"storage": "auto"} (or "keyring") in the global settings.json to move secrets into the OS secret store — macOS Keychain, Linux Secret Service (GNOME Keyring/KWallet over D-Bus), Windows Credential Manager. auth.json then keeps only metadata (type, label, expiry) plus a _fir_storage marker; secrets are one keychain item per slot under service fir. auto falls back to auth.json when no store answers (e.g. headless Linux without a session bus); keyring errors instead. Once migrated the marker is authoritative: if the store later becomes unavailable fir reports an error rather than silently writing secrets back to the file.
fir auth storage— show where secrets live, the setting, and whether the OS store answers.fir auth storage keyring— migrate now (each secret is written and read back before the file is stripped).fir auth storage file— move secrets back intoauth.jsonand delete the keychain items (setauth.storagetofilefirst, or the next access migrates them again).
Amazon Bedrock accounts
fir login bedrock configures one or more Bedrock accounts (these are credential configs, not OAuth flows). Three modes:
- iam-profile — a named
~/.awsprofile (SigV4). Profiles are seeded from~/.aws/config. - iam-keys — explicit AWS access/secret (and optional session) keys (SigV4).
- bearer — a Bedrock API key / bearer token (suppresses SigV4); seeded from
$AWS_BEARER_TOKEN_BEDROCKif present.
With no flags it prompts interactively; non-interactive flags: --mode, --account NAME, --region, --profile, --access-key, --secret-key, --session-token, --token. Each account can carry a per-account region (which selects the regional bedrock-runtime endpoint) and per-model inference-profile ARN overrides (stored in the credential's extra.modelOverrides). Like OAuth accounts, multiple Bedrock accounts coexist and switch live via the selector; the credential type is stored as aws_iam (profile/keys) or api_key (bearer) in auth.json.
Session observation (fir observe / fir send)
Every fir session writes a JSONL transcript to disk via SessionStore. The builtin observe extension registers a sidecar at $XDG_STATE_HOME/fir/agents/<session-id>.json containing discovery metadata (pid, socket path, transcript path, cwd, status, name) and listens on a Unix socket for input injection.
fir observe and fir send are CLI verbs of the observe extension (declared via cli_verbs: in its frontmatter), dispatched cold by fir's verb-dispatcher. There is no Go subcommand for either — the entire implementation lives in pkg/resources/builtin_extensions/observe.py. See docs/design/extension-cli-verbs.md for the mechanism.
The same extension also exposes:
/observeand/sendslash commands — usable from inside an interactive session for snapshot inspection / one-shot message injection.observe_session,send_session, andstop_sessionAI tools — let the agent inspect, nudge, or terminate sibling sessions as part of a turn.
Slash commands and tools take snapshots (last N transcript lines) and send single messages; they do not live-tail. Use fir observe <id> from another terminal for live observation. observe_session / /observe also accept a partial-range slice (start/end tool params, or --start=/--end= flags) to page through a long transcript by line number instead of pulling the tail.
The observe extension is a builtin and loads automatically in every session — no special flags needed. Observation commands run from any other terminal:
fir observe # list LIVE sessions (status idle/running/error/no-model/observe-lost)
fir observe --all # include ended + crashed sessions
fir observe --json # the list as JSON (one status object per session)
fir observe <id-prefix> # status + last 50 entries, then exit (on a TTY: follow live)
fir observe <id> -n 200 # snapshot of the last 200 entries; always exits
fir observe <id> -f # follow live until the session ends (Ctrl-C / Ctrl-\ to stop)
fir observe <id> --status [--json] # status only: idle|running|error|no-model|observe-lost|ended|crashed,
# model, current tool, run count, last error, startup notice
# crashed = fir host dead; observe-lost = host alive but
# not observable/sendable in real time (do not kill for it)
fir observe <id> --wait [--timeout S] # block until the current run ends (124 on timeout)
fir observe <id-prefix> --json # raw JSONL (snapshot; with -f a raw tail)
fir observe --cwd . # resolve by directory (symlinks resolved)
fir observe <id> --interact # follow + pipe stdin to the session
fir send <id> 'message' # send one message (args joined)
fir send <id> --wait 'message' # send, block until that turn ends, print the final reply
# (exit 1 if the turn failed, 124 on --timeout S)
echo "brief" | fir send <id> # piped stdin = ONE message (multi-line ok)
fir send <id-prefix> # on a TTY: interactive, Enter sends each line
fir send <id> --steer|--follow ... # deliver as steer (interrupt) / followUp (queue)
fir send <id> --abort # cancel the current turn
fir send <id> --model prov/id ... # switch provider+model (alone, or before the message)
<id-prefix> prefix-matches against session id, session name, or basename(cwd). First-line sigils in fir send: !message → steer, +message → followUp, ~ → abort, \!/\+/\~ to escape. Ctrl-\ detaches. In interactive fir send and fir observe --interact, a typed /model provider/id line switches the session's model instead of being sent. The send_session tool takes a model parameter for the same switch.
A steer lands at the next turn boundary: most tools (bash) finish first, but wait yields within ~1s (outcome=interrupted + resume handle) and pipe stops between steps.
Status comes from the core-owned session/status observable card (<transcript>.cards), which AgentSession writes in agent-loop order on every lifecycle transition (pkg/session/statuscard.go): idle, running (from prompt acceptance to agent_end; detail names the running tool), error (last run ended with a provider/auth error — the message is in error:), no-model (prompts are refused; the startup "No models available…" notice is in notice:). Detail is key: value lines (status, model, tool, runs, error, notice). Liveness additionally probes the session's input socket, so a reused pid does not resurrect a dead session.
Note: when using -e <name> flags to selectively enable extensions, include all required auth extensions too (e.g. -d auto-namer -d notify to suppress noise without blocking auth). Using -e observe alone blocks auth extensions.
For multi-pane monitoring: run fir observe <id> -f in one tmux pane and fir send <id> in another.
Session stores — reading other hosts' sessions (sessionStores)
fir writes sessions only to its own store, <agent-dir>/sessions/ (label local). Extra read-only stores — directories laid out the same way, e.g. a Syncthing copy of another host's ~/.config/fir/sessions/ — are configured in the GLOBAL settings.json (a project .fir/settings.json cannot add stores; fir warns and ignores it):
{ "sessionStores": [{ "path": "~/fir-traces/zbox", "label": "zbox" }] }
path must be absolute or ~/…; label defaults to the directory name, must match [A-Za-z0-9._-]+, be unique and not local. Stores are always read-only (readOnly: false is ignored with a warning). A missing directory is skipped, not an error; a path that overlaps fir's own <agent-dir>/sessions/ is dropped with a warning. FIR_SESSION_STORES=zbox=~/fir-traces/zbox[,label=path] replaces the setting for one run.
What sees them:
fir sessions list [--all|--cwd DIR] [--store L|--read-only] [--limit N] [--json]— TSVstore modified id msgs path cwd name first_message, newest first, no header (--json: one object per line). Archive sessions are listed from each transcript's.meta.jsononly (no.meta.json, or modified in the last minute = mid-sync, → skipped); a missing cwd is decoded from the directory name (lossy:-vs/).fir sessions dirs [--json]—store path readonly exists.fir sessions search PATTERN [--kind bookmark|transcript] [--cwd DIR] [--store L] [--since 72h] [--limit 50] [--per-file 3] [-i] [--json]— Go regexp, bookmarks first then transcripts, newest first (ctx-* logs excluded); hit =store kind id path:line snippet. Bookmarks are fast; a whole archive's transcripts take tens of seconds./resumepicker (labelled[zbox]),fir --resume <id|label:id>, ACPsession/list(title prefixed[label],_meta: {"fir/store": label, "fir/readOnly": true}), ACPsession/resume/load(paths inside a configured store are accepted; anything outside every store is still rejected), ACP/resume, andobserve_session all=true/fir observe --all(newest 20 asarchived:<label>;fir observe <id|label:id>snapshots an archived transcript).
Resuming an archive session forks it into the local store (message: "Session is in read-only store … forked"); fir never locks, repairs, writes state, cards, sidecars or metadata inside a read-only store — the check lives in the session store itself. The recall skill searches every store through fir sessions.
Driving a fir agent on another host
observe_session / send_session / stop_session see only sessions on the local machine. For a session on another host, run the CLI there over ssh (rexec) — snapshots need no TTY and always exit:
# start a named, headless-ish agent (tmux is just the process holder)
ssh HOST "tmux new -d -s w1 -c ~/proj 'fir --session-name w1'"
ssh HOST fir observe # what is running there, with status
ssh HOST fir observe w1 --status --json # machine-readable status / last error
ssh HOST fir send w1 --wait --timeout 30m < brief.md # send, wait for the turn, get the reply
ssh HOST fir observe w1 -n 40 # what it did
ssh HOST "fir send w1 '!new direction'" # steer; fir send w1 --abort cancels
Scraping the tmux screen is a fallback only. For a long-lived human view: ssh -t HOST 'tmux new -As watch "fir observe <id> -f"'.
Environment Variables
{{FIR_ENV_VARS_TABLE}}
Adding Custom Models
fir's model list is the union of built-in models and anything you add. Three ways to add a model so it shows up in /model, --list-models, and is selectable as a default:
1. models.json (preferred — persistent, visible everywhere)
Drop a models.json in ~/.config/fir/ (global) or .fir/ (project). Add models under the relevant provider — they appear alongside built-ins:
{
"providers": {
"amazon-bedrock": {
"models": [
{
"id": "arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abc",
"name": "Opus 4.6 (Bedrock app profile)",
"api": "bedrock-converse-stream",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 200000,
"maxTokens": 8192,
"sweScore": 73.5
}
]
}
}
}
The same file also supports per-provider overrides (baseUrl, apiKey, headers, authHeader) and per-model overrides under modelOverrides. Most model fields (cost, contextWindow, maxTokens, sweScore, compaction, serverTools, …) can be set on a custom model or supplied via modelOverrides to patch a built-in. sweScore (SWE-bench Verified %, 0–100) shows up in the /model selector and drives its sort order.
api — the wire protocol (and its silent failure mode)
api selects the wire protocol, not the vendor. Legal values are the API
constants in the external module github.com/kfet/ai (types.go, at the
version pinned in fir's go.mod; re-check with
grep 'API =' "$(go env GOMODCACHE)"/github.com/kfet/ai@*/types.go, aliased in
fir as pkg/ai/aliases.go):
openai-completions, openai-responses, azure-openai-responses,
openai-codex-responses, anthropic-messages, bedrock-converse-stream,
google-generative-ai, google-vertex.
Extensions may register further wire protocols at runtime (ext-api:*, ext:*).
OpenAI-compatible endpoints (OpenRouter, Groq, local servers, …) take
openai-completions — not "openai", which is a provider name, not an API.
Neither mistake is caught when the config loads:
- Unknown
api— the model still appears in--list-modelsand/model; every request to it fails withno API provider registered for api: <value>. - Missing
apion a provider fir has no built-in for — validation rejects the whole custom-model load (Provider X, model Y: no "api" specified. Set at provider or model level), so every custom model disappears and--provider XreportsUnknown provider "X". That error text is shown only in the interactive/modelselector, not by--list-models.
api can be set per provider and per model: model-level wins, then
provider-level, then — for built-in providers only — the API of that provider's
first built-in model. So a custom model on a built-in provider may omit api; a
model on a brand-new provider may not.
Unix-socket endpoints (unix://)
A baseUrl may use the unix:// scheme to reach an HTTP endpoint over a unix
domain socket, e.g. unix:///run/boxres/bifrost.sock/anthropic. Rule: the
socket path is the URL path up to and including the first path segment
ending in .sock (/run/boxres/bifrost.sock); the rest (/anthropic) is the
HTTP path, sent with Host: localhost (so requests go to
/anthropic/v1/messages). A unix:// URL with no .sock segment is an error.
Works for every HTTP-based API (anthropic-messages, openai-completions, …),
streaming included.
A custom provider may omit apiKey when every model's effective baseUrl
is unix:// — access is controlled by socket permissions/peer credentials, and
fir sends no auth header:
{ "providers": { "bifrost": {
"baseUrl": "unix:///run/boxres/bifrost.sock/anthropic",
"api": "anthropic-messages",
"models": [{ "id": "claude-sonnet-4-6" }] } } }
apiKey and header values are resolved the same way: a value starting with !
is run as a shell command and its stdout used; otherwise the value is used as an
env var name if that variable is set, else taken literally. There is no
${VAR} interpolation inside a string.
Provider routing: OpenRouter and Vercel AI Gateway
Aggregators route one model id to whichever backend provider they pick. Those backends are not interchangeable: they run different serving stacks, so the same id can vary in vision support, reasoning-effort handling, quantization, context window and latency between requests. If reproducibility matters, pin the backend rather than trusting the default "best available" routing.
Both aggregators are configured through the per-model compat block — either
on a custom model under models, or on a built-in via modelOverrides. It is
a model-level key: there is no provider-level compat. Routing is emitted
only when that model's baseUrl contains openrouter.ai /
ai-gateway.vercel.sh respectively, so a stray key elsewhere is inert, not an
error.
{ "providers": { "openrouter": {
"modelOverrides": {
"moonshotai/kimi-k2.5": {
"compat": {
"openRouterRouting": {
"only": ["moonshot"], // exclusive allow-list
"order": ["moonshot", "together"] // try in this sequence
}
}
}
} } } }
only— providers exclusively allowed. This is the reproducibility knob.order— ordered preference; other providers may still serve as fallback.ignore— providers to skip.allowFallbacks(default true) — setfalseto fail rather than silently route to a backend outsideonly/order.requireParameters— only route to providers supporting every parameter in the request (the vision / reasoning-effort mismatch guard).zdr— restrict to Zero Data Retention endpoints.dataCollection("allow"|"deny") andenforceDistillableTextare the related privacy filters.quantizations— e.g.["fp8"], to exclude aggressively quantized serves.sort—"price"|"throughput"|"latency", or an object withby/partition.maxPrice—{prompt, completion, image, audio, request}USD per MTok caps.preferredMinThroughput/preferredMaxLatency— a number, or an object ofp50/p75/p90/p99cutoffs.
Key names are camelCase in fir config and are translated to OpenRouter's
snake_case on the wire (allowFallbacks → allow_fallbacks). Fields whose
OpenRouter schema is a union of a scalar and an object (sort,
preferredMinThroughput, preferredMaxLatency) are forwarded verbatim without
local validation — a malformed value is rejected by the API, not by fir.
Vercel AI Gateway uses vercelGatewayRouting with only / order only; it is
emitted as providerOptions.gateway.
Discover valid provider slugs for a model with OpenRouter's endpoints API:
curl -s https://openrouter.ai/api/v1/models/moonshotai/kimi-k2.5/endpoints \
| jq -r '.data.endpoints[] | "\(.provider_name)\t\(.context_length)\t\(.quantization)"'
Current limit: none of these are validated locally — an unknown provider slug simply routes to nothing and OpenRouter answers with an error.
Drop-in fragments: models.d/
Alongside models.json, fir reads every *.json file in a models.d/
directory next to it — ~/.config/fir/models.d/ (global) and
.fir/models.d/ (project). Each fragment has the same shape as
models.json. This lets tools and agents add a model or provider by
dropping in a self-contained file, with no edits to a shared monolith.
Load order and merge semantics:
models.jsonis the base layer; fragments are deep-merged on top in lexical filename order — useNN-prefixes (e.g.10-fable.json,20-internal.json) to control precedence.- Within a provider: scalar fields (
baseUrl,apiKey,api) andauthHeaderare overwritten when a later layer sets them;headersandmodelOverridesmaps are key-merged; themodelsarray is concatenated and de-duplicated byid, last writer wins. - A fragment alone is sufficient —
models.jsonneed not exist. - A malformed or
providers-less fragment surfaces a load error (visible via the registry error /--list-models), it is not silently skipped.
// ~/.config/fir/models.d/10-fable.json
{ "providers": { "anthropic": { "models": [
{ "id": "claude-fable-5", "name": "Claude Fable 5",
"contextWindow": 200000, "maxTokens": 64000 } ] } } }
The catalog is read at process start, and re-read on /reload (which now
also refreshes the model registry) and when the model selector opens. So a
newly added fragment is picked up by /reload in a running session, by the
next fir process, or — for the Poe relay's dropdown — after a relay
restart (the relay snapshots the catalog once at its own startup).
Catalog overlay (fir-dist)
Below your config, above the compiled-in catalog, fir merges a catalog
overlay: a models.d-shaped document published as a static file at
https://raw.githubusercontent.com/kfet/fir-dist/main/catalog-v1.json. It
exists so a newly-released model can reach the whole fleet as data — no
binary release, no redeploy. It can add models, correct metadata, and move a
provider's default model (providerDefaults). A provider default is only a
product-line anchor: it resolves to the newest registered generation of the
same line (e.g. claude-opus-5 → claude-opus-5-5 once that model is
registered), keeping prefixes, variants and -preview tags, skipping dated
snapshots. Explicit pins (defaultModel, --model) are not advanced.
Full precedence, lowest to highest:
compiled-in catalog < fir-dist overlay < models.json < models.d/*.json
Your config always wins. The overlay may not set apiKey, baseUrl,
headers or authHeader at any level (provider, model, or modelOverrides) —
endpoint and auth stay under operator control, so a typo in a published catalog
can never redirect the fleet's credentials. It follows that the overlay can only
add models to providers fir already knows about.
- Fetched at startup and then every hour for the life of the session, cached
at
<agent-dir>/cache/catalog-v1.json; a changed catalog is hot-applied, so even a bot process that has been up for weeks follows within the TTL. A binary also embeds a snapshot, so fir works fully offline; every failure (no network, 404, malformed JSON, unknown schema version) is soft and logged at debug, never a degradation. - Force a refresh with
/reload, which refetches ignoring the TTL and hot-applies a changed catalog to the running session. fir --list-models --verboseprints each model's origin (builtin/overlay/user:models.json/user:models.d/<file>) so you can see which layer won.
Client version pins (clientVersions). The overlay also carries a small,
closed set of third-party client version scalars — currently just
clientVersions.claudeCode, the Claude Code CLI version the anthropic-auth
extension advertises as claude-cli/<v> (external, cli). Anthropic gates new
models on it, so shipping it as data means a gated model and the version pin it
requires arrive together in one document, instead of costing a one-line binary
release.
- The extension holds no version literal: it declares the template
claude-cli/{clientVersion.claudeCode} (external, cli)and fir expands{clientVersion.<key>}in header values only (never names, never URLs) at token-request time and afterauth/modify_models. Hot: a published change takes effect within a TTL with no restart. - Values must match
^[0-9]+(\.[0-9]+){0,3}$and be ≤ 32 bytes, so nothing free-form can ride in. - The value compiled into the binary is a floor: a published document can only ever advance the pin, never roll a host below what it shipped with.
- To read what a session is advertising:
/reloadnames it in its status line (… (claude-code 2.1.280)); the committed value isclientVersions.claudeCodeinpkg/models/catalog-v1.json. - The nightly
model-watchjob reads npmdist-tags.latestfor@anthropic-ai/claude-codeand proposes a forward-only bump on the same rolling PR as new models; a moved pin alone is enough to open one. - Detecting that a bump is needed. When an OAuth Anthropic request is
rejected with the vendor's gate sentence (
Claude Code X does not support this model; version Y or newer is required), the error is rewritten to name the pin, its source (overlay vs embedded floor) and the remedy, and aclient-version-gaterecord (pin, pinSource, required, model, host, ts, vendor error) is appended to~/.config/fir/doctor.jsonl—doctor_query pattern=client-version-gatelists them. Every session start prints a one-line warning while any record is unresolved; it clears itself once the effective pin moves strictly past the rejected one (no manual step).fir doctor client-version-gatesprints the unresolved ones (nothing when clear); fleet converge publishes that as~/sync/shared/fleet/status/<host>.gates.
Escape hatches (env vars):
| Variable | Effect |
|---|---|
FIR_NO_CATALOG_OVERLAY=1 |
Disable overlay fetching. The embedded snapshot still applies — this pins a host to the catalog its binary shipped with. |
FIR_CATALOG_OVERLAY_URL=<url> |
Fetch the overlay from somewhere else (staging file, local server). |
Publishing: edit pkg/models/catalog-v1.json in the fir repo and merge to
main; the publish-catalog workflow pushes it to fir-dist. To roll a
catalog change back, re-publish the old content with a fresh generatedAt —
a binary loads whichever of its embedded snapshot and its cache is newer, so
reverting the commit alone can be a no-op.
2. Environment variables (Claude Code parity)
Set CLAUDE_CODE_USE_BEDROCK=1 and ANTHROPIC_MODEL=<id-or-arn> (plus standard AWS auth — AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, optionally AWS_REGION). At startup fir registers the model under amazon-bedrock automatically so it appears in /model. Explicit --provider/--model flags still win.
ANTHROPIC_MODEL accepts a regular Bedrock model id (e.g. us.anthropic.claude-opus-4-6-v1) or a full Bedrock ARN (e.g. arn:aws:bedrock:us-east-1:123:application-inference-profile/abc).
3. CLI one-off
fir --provider amazon-bedrock --model <id-or-arn> works without any config — fir clones a sibling Bedrock model, substitutes the id, and routes through ConverseStream. Not persisted; not shown in /model until restart with models.json or env vars.
Extensions
Extensions are standalone scripts (Python, shell, etc.) in .fir/extensions/ (project) or <agent-dir>/extensions/ (global). They communicate with fir over JSON-RPC 2.0 on stdio and can register custom tools, slash commands, and event handlers.
A Python SDK is provided (fir_ext.py). No code changes needed to add an extension — just drop a script in the directory.
Disable an extension with chmod -x; a renamed file still loads.
Control loading via settings.json "extensions" allowlist, --extension/-e flags, or --no-extensions.
Extensions can optionally self-restrict by mode using script comment frontmatter (# modes: ...), e.g. tui, acp, or text. Mark an extension # explicit: true to make it opt-in only — it stays discoverable (so fir -e <name> and listings find it) but is not auto-loaded; it runs only when named in the allowlist. The shipped demo and hello example extensions use this.
Builtin extensions (notify, tmuxspinner, plan_nudger, handoff_nudger, etc.) are embedded in the binary and auto-discovered at lowest priority. Use fir extensions to list them and fir extensions install <name> to extract one for customisation.
handoff-nudger watches context usage after each turn and prepends a [SYS_EXT] note telling you to wrap up and self_handoff — auto-compaction only fires at 70% and is lossy, while cache-read cost grows linearly with conversation length. Two triggers: the ceiling, the lower of atTokens (500000) / atPercent (65) of the window; and a cold prompt cache — 30% of that ceiling (floored at 100000) once the session has been idle past idleMinutes (65), since the provider's 1h cache has expired by then and every further turn pays full input on the whole prefix. Tune or disable it in handoff-nudger.json (atTokens, atPercent, idleMinutes, nudgeEvery, off) in .fir/ or ~/.config/fir/.
Context provenance (ctx) — while the builtin ctx extension runs, fir core logs every chunk of each provider request to ctx-<session-id>.jsonl next to the transcript (kind:src rows {turn, addr, source, owner, tokens, hash} on first sight; one kind:turn row per call with model, tokens and present addrs; tokens ≈ bytes/4). Sources: system prompt, AGENTS.md, SYS_EXT section, SYS_EXT, skills index, tool schema, lesson, skill read, tool result, user msg. Chunks ≥100 tokens (and every lesson) carry a stable [ctx:<5-hex>] tag (tool results: [ctx: …] meta) on the provider copy only — history is untouched and tags are content-derived, so caches hold. The model may call ctx_used(addrs, why?) to log chunks it used (kind:cite rows). The ctx observable card shows tokens · sources · cited (observe_session ext=ctx: cited, costliest idle, duplicates). /ctx [model] reports across sessions per model sorted by cost. After reeval_turns (default 300; ctx.json or FIR_CTX_REEVAL_TURNS, 0 = off) new turns per model it injects a review prompt asking for trim/dedupe proposals as a diff — never applied; pinned patterns (default system prompt, auth, safety, security, format, copyright) are marked non-trimmable; per-model scores in the global ctx-state.json (never the project .fir/). Disable with --extension/disabled-extensions like any extension.
Extension sections — each extension may own one persistent section of standing context (ctx.set_section(text) / ctx.clear_section()), stored by fir at <agent-dir>/sections/<ext-name>.md. Fir reads that directory itself before the first turn (so fir -p sees sections without waiting on extension events) and injects the sections of installed, enabled and trusted extensions as one [SYS_EXT sections] message right after the system prompt (cache breakpoint after it; no timestamps, byte-stable). Mid-session changes are re-injected in full as [SYS_EXT section=<name> vN replaces vM] on the next turn. Caps: ~500 tokens per section, ~2000 total; over-cap writes are rejected, never truncated. Disabled extensions keep their file (not emitted); uninstalled extensions' sections are deleted at startup. /sections shows what is injected. The builtin mood extension publishes its lessons this way. Fir core also owns one per-session section, [section=mcp] (never written to disk): one line per configured MCP server that is unhealthy (needs_auth, token_expired, error, disabled) with the fir command that fixes it, e.g. MCP server slack: configured, NOT AUTHENTICATED, tools unavailable -> fir command: /mcp login slack. It is empty when all servers are healthy; the first turn waits up to 5s for initial MCP connects so it reflects real state, and changes are re-injected next turn.
Extensions can be reloaded via /reload; there is no automatic file watching. Reloading extensions rebuilds the system prompt, which might impact prompt cacheing.
Extensions can also register top-level CLI verbs via the cli_verbs: frontmatter key — fir <verb> [args...] spawns the extension cold (no session) and routes via a cli_invoke JSON-RPC request. The extension drives fir's TTY through host.println()/host.readline() etc. Built-in subcommands cannot be shadowed; verb collisions are a startup error. fir observe and fir send are the first users; see docs/design/extension-cli-verbs.md and docs/extension-protocol.md § CLI Verbs.
Skills
Skills are Markdown instruction files at .fir/skills/<name>/SKILL.md with YAML frontmatter. They provide specialized workflows the agent can follow. Skills are auto-discovered from project, user, and builtin directories.
Use /skills to list loaded skills, /reload to pick up changes. Use fir skills to list all skills, fir skills show <name> (or shorthand fir skills <name>) to inspect one — flags --full to dump the SKILL.md body, --path to print only the file path — and fir skills install <name> to extract a builtin skill for customisation.
Skills can also be invoked directly from the CLI: fir /<skill-name> [task...] rewrites into an initial agent message that points at the named skill and supplies the rest of the positional arguments as the task body. Composes with all flags (-p, --model, -c, …). Bash and zsh completion enumerate /<skill> candidates dynamically from fir skills list.
fir /<name> also runs slash commands: if no skill matches, the name is resolved against the built-in slash commands (/changelog, /session, /mcp, /model X, /theme, /skills, /plan, …) and extension-provided commands, and dispatched exactly as if typed at the interactive prompt. Resolution order is skill first (back-compat), then command. Builtin slash commands are declared once in pkg/slashcmd and shared by the TUI and ACP (each marked mode-only where it is, e.g. TUI selectors like /theme, ACP's /continue); /help lists the ones available in the current mode. fir /<name> is TUI-bound, so in headless modes (-p, --output-mode json, ACP) they fail fast with command /x requires interactive mode rather than being sent to the model.
External Packages
Install/manage external packages (git repos or local paths) that contribute skills, extensions, and themes:
fir install github.com/user/fir-pack # install to user scope (<agent-dir>/packages/)
fir install ./local/path --local # install to project scope (.fir/packages/)
fir uninstall github.com/user/fir-pack # remove a package
fir packages list # list installed packages (source, scope, skill/ext counts, path)
fir packages update [source] # pull latest for one or all packages
Packages are stored in settings.json under "packages". Each entry is a string ("github.com/user/repo") or an object with "source" and optional per-type filters. Installed package skills, extensions, and themes are automatically loaded.
Agent Plugins. A package whose root has plugin.json is loaded as an Agent Plugin (v1.0.0) instead: plugin.json is validated (invalid → the whole plugin contributes nothing), skills come only from skills/*/SKILL.md (invalid ones skipped), and <plugin>/mcp.json servers join MCP config as the lowest layer (user/project entries of the same name win, with a warning). Stdio servers run with cwd = plugin root and get PLUGIN_ROOT and PLUGIN_DATA (<agent-dir>/plugin-data/<name>-<source-hash>/, or .fir/plugin-data/… for --local; created only for plugins with stdio servers; kept across updates). fir.json and auto-discovery are ignored for plugins. Diagnostics print at fir install and in startup MCP warnings. fir reserves the client namespace dev.fir (no contents defined). User guide: docs/agent-plugins.md. Code: pkg/agentplugin/.
Resource Lookup Paths
Skills and themes are discovered from multiple locations. Each loaded resource is tagged with an origin that identifies where it came from:
| Origin | Where |
|---|---|
builtin |
embedded in the binary |
user |
<agent-dir>/skills/ (and equivalents for themes) |
project |
<cwd>/.fir/skills/ |
pkg:<source> |
installed package, e.g. pkg:github.com/kfet/foo |
path:<basename> |
path supplied via --skill <path> or the "skills" settings array |
Origins are surfaced via the <name>__<id> form below; they are not
hierarchical "wins one, loses another" priorities.
Coexistence and Disambiguation
Same-named skills coexist by default. A user skill called release and a
package skill called release will both load. Each gets a unique ID of the
form <sanitized-origin>__<name>, e.g. user__release and
pkg_github_com_kfet_release_tools__release (every character outside
[a-z0-9_] in the origin is replaced with _, MCP-tool-name style).
The agent references skills by bare name when unique, and by full ID
when ambiguous. The <available_skills> block in the system prompt
automatically switches to the disambiguated form when a name conflict
exists, and notes this to the agent.
fir skills list shows the ID column and tags rows with [ambiguous] or
[overrides …]. fir skills show <name-or-id> accepts either form; if a
bare name is ambiguous it prints the candidate IDs and exits non-zero.
Slash invocations (fir /<name>) follow the same rule.
Overriding Another Skill
To replace another same-named skill instead of coexisting with it, add
override: to your skill's frontmatter:
---
name: release
description: My customised release flow
override: true # replace any other skill named "release"
---
Two forms are supported:
-
override: true— your skill replaces every other skill with the same bare name. Use this for the common case ("I want myreleaseto be the release skill"). When two skills both claimoverride: true, the highest-precedence origin wins (user > project > path > pkg > builtin), the loser stays loaded under its disambiguated ID, and a warning is emitted. -
override: <full-id>— your skill replaces one specific target. Useful when you want to coexist with some same-named variants but shadow one in particular:override: pkg_github_com_kfet_release_tools__releaseThe origin portion may be written either as the raw form (
pkg:github.com/…) or the sanitized form (pkg_github_com_…); both are accepted.
Diagnostics emitted at load time:
duplicate-name(info) — same-named skills coexist; reference by ID.shadowed(warning) — a skill was suppressed by an override.override-conflict(warning) — multiple skills claimedoverride: truefor the same name; the highest-precedence one won.
The same scheme applies to extensions and themes (rolling out; skills are first).
Settings Path Resolution
Paths in "skills", "themes", and "extensionPaths" settings arrays support three forms:
| Form | Example | Resolves to |
|---|---|---|
| Absolute | "/opt/shared/skills" |
/opt/shared/skills |
| Home-relative | "~/my-skills" |
$HOME/my-skills |
| Relative | "skills" |
$CWD/skills (resolved at startup) |
Relative paths are resolved against the current working directory, not against the settings file. This is intentional — it makes relative paths portable across projects:
// In ~/.config/fir/settings.json (global):
{
"skills": ["skills"], // → finds ./skills/ in any project that has one
"extensionPaths": ["extensions"] // → finds ./extensions/ in any project
}
This lets you establish a convention (e.g. "every project keeps skills in ./skills/") and have fir discover them automatically without per-project configuration. Paths that don't exist are silently skipped.
For project-specific settings (.fir/settings.json), relative paths also resolve against cwd (the project root), which is the natural expectation.
Background jobs in the bash tool
Background jobs started by the bash tool (cmd &, nohup cmd &, (sleep N; cmd) &) keep running after the call returns. Output goes to an in-memory pipe, not to files. The result gives a job id. bash job=<id> returns the job's status and the output since the last read, from a 1MB ring buffer; job=<id>, kill=true stops it; jobs=true lists jobs. An exited job is forgotten once it has been read. Timeout or abort kills the whole group. At most 10 background jobs run at once; a call that would start an 11th fails with an error listing the running jobs. Surviving jobs are killed (SIGTERM, then SIGKILL) when the fir process exits or before /reexec, and their pipes are closed. If fir crashes, the kernel closes the read ends and the jobs die of SIGPIPE on their next write. Jobs do not outlive the session, so for a delayed action wait inside the session, or use watcher_add.
Remote hosts (remote extension)
The builtin remote extension gives the agent six tools for working over ssh, so remote work never needs hand-written ssh one-liners inside Bash:
rexec(host, command, timeout_s, cwd, detach)— run a shell script on a remote host. The script is shipped on ssh stdin tobash -l -s, never asssh host "bash -lc '...'", so nothing is re-split by an intermediate shell and no escaping is needed. Thetimeout_sbound is a one-line bash supervisor passed as the ssh command — it backgrounds thatbash -l -sunder job control (set -m), so the command leads its own process group (TERM then KILL on expiry, exit 124) while still reading your script straight off the ssh channel. Not GNUtimeout, which does not exist on macOS.detach=Trueruns it undersystemd-run --user --collect(fallbacks: double-forkedsetsid, thenset -m+nohupfor macOS) on the remote box and returns a job id.rjob(host, id, action)—status/log/tail/killfor a detached job. State lives entirely in~/.cache/fir/rjobs/<id>.{log,rc,pid}on the remote host — there is no local registry to drift.rput/rget— scp file transfer sharing the same flags and result envelope.rtmux(host, action, ...)—ls | new | send | cap | kill. tmux on the remote host is the PTY; every action is a stateless short exec (no heldssh -tt).capreturnsunchanged: truewhen the pane is byte-identical to the last capture, so polling is cheap.rhosts(probe)— listHoststanzas from~/.ssh/config(Includes followed), optionally with a parallel reachability sweep.
All connection flags (BatchMode, ConnectTimeout, ServerAliveInterval, and a ControlMaster mux at ~/.ssh/fir-cm-%C with ControlPersist=120) are owned by the extension. Hosts come from ~/.ssh/config; there is no fir-side host registry. Every tool returns a discriminated envelope — outcome is one of ok | nonzero_exit | timeout | unreachable | auth_failed | no_tmux | no_target, with stdout_bytes always explicit. Only transport/target failures are tool errors; a remote command exiting nonzero is ordinary signal.
Self-host detection. A fleet agent frequently asks for the box it is already running on by alias, and ssh loopback fails with Permission denied (publickey) because a host's key is not in its own authorized_keys. rexec / rjob / rput / rget detect this and run locally instead, under an sshd-shaped scrubbed environment ($HOME cwd, bare PATH, no TMUX/FIR_*/SSH_AUTH_SOCK, same login shell, same timeout supervisor, plus the XDG_RUNTIME_DIR that pam_systemd would set so detach=True still gets a systemd-run --user unit). The envelope then carries local: true, and rhosts marks the entry self: true. The test is not a hostname comparison: ssh -G resolves the target, a ProxyCommand/ProxyJump, a non-22 port, a different target user or an ip_nonlocal_bind kernel all bail straight back to ssh, and otherwise each resolved address is bind()-tested — an address binds only if it is configured on a local interface. Pass via_ssh=True (or set FIR_REMOTE_FORCE_SSH=1) when the ssh hop itself is what you are testing. rtmux never runs locally: it refuses a self-host outright, because send-keys into the local tmux server can land in the agent's own pane — use the tmux-driver skill there.
These tools make teleoperation cheap, which is a trap: for substantial work on another host, rput a brief and rtmux new an agent over there rather than driving the job one call at a time.
Key Concepts
-
Sessions — conversations are persisted and can be continued (
-c) or resumed (-r). Sessions form a tree; double-Escape or/treenavigates branches./session(orCtrl+S) toggles a sticky, collapsible session-info overlay above the editor (version, IDs, message/token stats, enabled extensions); toggle it again, orCtrl+L, to dismiss it without leaving anything in the transcript. Use/new [prompt]to start a fresh session, optionally with an initial prompt that is submitted immediately./new(andCtrl+N) drop any queued messages and say so ("Discarded N queued messages"); aself_handoffinstead carries them into the new session, delivered after the handoff prompt — preceded by any user message the old session had not yet answered ("N unanswered messages carried into the new session"). Resuming a file (non-interactive):fir --resume <file.jsonl> -p '...'appends the turn to that file (a value is taken without--atonly when it is an existing file or ends in.jsonl). Forking at an entry (non-interactive):fir --resume <session> --at <entry-id>(or--session <path> --at <entry-id>) is/resume+/treeto that entry in a new process: it writes a child session (headerparentSessionpoints at the source; recorded invocation is inherited) holding the root→entry path and continues there; the parent file is never written.<session>is a path, a file name, or a session id fragment. An entry that leaves an assistant tool call without its result is rejected. The child's history is byte-identical to the parent's prefix, so on the same model/tools the Anthropic prompt cache hits. A running agent learns its ownsession.file,session.leafIdandsession.forkAt(the newest entry--ataccepts — the leaf, unless the leaf is mid tool call) fromagent_introspect. ACP:session/fork(capabilitysessionCapabilities.fork) forks at the parent's forkable leaf, or at_meta.at: "<entry-id>"(advertised asfork._meta.at: true); the response'ssessionIdnames the child. Eachsession/promptresponse carries_meta.leafId(the turn's leaf; advertised aspromptCapabilities._meta.leafId, omitted if mid tool call) — pass it as_meta.atto fork exactly after that turn. Legacy fir-only:"at"onsession/load/session/resume(spec route:_meta.at, child in_meta.sessionId). ACP/resume <n|path> --at <id>does the same in-session. Subagent with context: fork yourself. Default way to start a subagent that needs this session's context (instead of a fresh session or anasidedelegate that re-explores):agent_introspect→session.file,session.forkAt.- In the SAME cwd on the SAME model:
fir --resume <file> --at <forkAt> --model <same> -p '<task; return a short summary>'(ACP:session/fork). - Use only the child's summary; the parent file is untouched.
Rules: same model and tool set or the cache misses (an
asidedelegate on a cheaper model always misses); fork promptly (5-min cache TTL); the child inherits the full context, so don't fork near the context limit; children that write files need their own worktree; prefer it when the parent holds >~30k tokens of useful context, use a fresh session for unrelated work. Measured 2026-10-03 on fir 1.25.0: forking a 97.7k-token session gave first-turn cacheRead 96,131 / cacheWrite 711 (~98% cached); on 1.24.4 (before the prompt split) cacheRead was 0. -
Re-exec for local build testing — use
/reexecto restart into the current binary while preserving the active session, or/reexec <path>to switch to a specific built binary. -
In-place update — use
/updateto check for, download, and install the latest release, then automatically restart the session. -
Compaction — when context grows large, fir automatically summarizes older messages to stay within the model's context window. Configurable via
settings.json. -
Review & Fix — use the
review-and-fixskill to run a one-shot review pass over recent changes. Covers build breaks, security, correctness, test gaps, and simplification (code reuse, quality, efficiency). Fixes all issues found, verifies the build, and commits. -
Aside (ephemeral side queries) — use
/aside <question>to ask a quick side question using the current session context without adding the exchange to history. The response is shown as a notification and the main conversation is unaffected. Works even while a task is streaming. When given tool calls,/asideorchestrates multi-tool workflows ephemerally viactx.call_tool()+ctx.side_query()— raw tool outputs never enter the main conversation, only the synthesis is returned. Use/aside read the 5 largest .go files and summariseto ask the agent to build and run an aside automatically. Theasidetool can also be called directly by the agent — including in agentic delegate mode: passgoal(withdelegate=true, mutually exclusive withtools) and the cheap delegate model drives its own read-only calls (whichever ofread/glob/grep/lsthis session has) in a loop until it can answer, bounded bymax_iterations(default 8, clamped 1-20), a total tool-output cap and a wall clock; narrow the tool set withallow_tools. Agentic mode is delegate-only and never falls back to the executor model. Escalation (escalate=true) has amode:self(advisor sees the full session, the default behaviour),strong(the model set as"strong_advisor"inaside.json— by default the strongest available advisor chain, Fable first — sees only a composed brief — original task, last few turns, the turns named inrefs(ids or exact quotes), and the question — kept as one append-only, cache-friendly thread per session+model, compacted past ~40k tokens), orauto(default: strong when the user asks for it, whenhigh_stakes=true, or when self was already consulted on the same problem; else self). Strong falls back to self with a note only whenstrong_advisoris"off"or no strong model is available. Every aside result (and/advise) ends with a token-accounting footer —[in 1.2k · read 48.3k · write 612 · out 900]— summed over every LLM call the escalation made;readvswriteshows whether it hit the advisor prompt cache. For a subagent that needs this session's context to do work, fork yourself (see Sessions) rather than an aside delegate, which re-explores and misses the cache. Advisor replies that are only tool calls (native or written as<invoke>text) are rejected and retried, never returned; the default advisor chain ends with a non-adaptive model at effortoff(add one yourself, e.g.anthropic/claude-sonnet-4-5:off, to an explicitaside.jsonchain); every failed advisor attempt is logged to the doctor log asadvisor_failurewith akind. -
Thinking levels — control reasoning depth: none, minimal, low, medium, high. Toggle with
Shift+Tabor--thinking. -
Tool steering —
"steeringMode"in settings controls whether the agent runs tools one-at-a-time or in parallel. -
call_tool bridge — extensions can call any registered tool (built-in, extension, or MCP) programmatically via
ctx.call_tool(name, params). Results are returned directly and never enter conversation history. This enables extensions to build rich orchestration workflows. -
MCP channel servers — MCP servers that advertise the
claude/channelexperimental capability can push messages into the running session vianotifications/claude/channelnotifications. Messages are injected into the agent conversation automatically. The server'schannel_replytool is a regular MCP tool. Configure channel servers in.fir/mcp.jsonlike any other MCP server — no special config needed. -
MCP configuration — configure MCP servers in
.fir/mcp.json(project) or<agent-dir>/mcp.json(global, e.g.~/.config/fir/mcp.json). You can also drop per-server files into the global~/.config/fir/mcp.d/*.jsondirectory — handy for managing one MCP per file. All sources are merged with precedence (low to high): (1)~/.config/fir/mcp.json, (2)~/.config/fir/mcp.d/*.json(lexically sorted), (3)<project>/.fir/mcp.json.Field reference — read it, do not recall it. The complete, authoritative definition of every
mcpServersentry and everyauthkey lives inmcp-config-contract.md, next to this file in the skill directory. It is fir's own source, embedded verbatim, so it can never drift from the running binary. When a user asks what a field does, which fields exist, what a default is, or whether some key is still supported, read that file and answer from it. Do not answer such questions from memory of this skill, and do not reproduce the field list here. What follows is the operational half: worked examples, the login flow, and how to choose.Three transports are supported:
- stdio (default) — launches a local subprocess:
{ "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/home/user/projects"], "env": { "NODE_PATH": "/usr/local/lib/node_modules" } } } } - streamable (recommended for remote) — connects to a remote HTTP MCP server:
{ "mcpServers": { "my-remote": { "transport": "streamable", "url": "https://my-server.example.com/mcp" } } } - sse — connects via Server-Sent Events (legacy HTTP transport):
{ "mcpServers": { "legacy": { "transport": "sse", "url": "https://old-server.example.com/sse" } } } - Any server may also declare
roots— a list offile://URIs that fir advertises to the server as its filesystem roots, for servers that scope their operations to them:
When omitted (the default) fir advertises a single root: the process working directory. Entries are sent verbatim, so they must be{ "mcpServers": { "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"], "roots": ["file:///home/user/projects", "file:///srv/data"] } } }file://URIs, not bare paths.
- stdio (default) — launches a local subprocess:
-
MCP files extension (experimental) — per-server opt-in
"files": {"enabled": true}turns on the draftio.modelcontextprotocol/filesextension, used only with servers that advertise it on protocol 2026-07-28+. Tool arguments markedx-mcp-fileare shown to the model as{"path": "<local file>"}; fir checksaccept/maxSize, sends the file inline (within the server'smaxInlineBytes) or uploads it to the server'supload_requiredtarget and retries the call, so neither bytes nor target URLs enter context. Download targets in a result's_metaare fetched (size/digest checked, 100 MiB default cap viafiles.maxDownloadBytes) into a private temp dir and the saved path is appended to the result. Sending a file needs approval: ACP asks the client withsession/request_permission; TUI and headless sessions have no prompt and refuse unlessfiles.autoApproveis set. Targets: https only, no redirects, MCP credential only oncredentials:"include"same-origin/pathtargets, no private addresses;files.allowedOriginsrestricts targets and is the only way to allow loopback/private or plain-http loopback targets. Design notes:docs/mcp.md(Files extension). Code:pkg/mcp/files*.go. -
MCP authentication (OAuth) — remote (
streamable/sse) MCP servers need no configuration to authenticate. fir connects unauthenticated; on a401it runs the MCP authorization-spec discovery chain automatically — RFC 9728 protected-resource metadata (from theWWW-Authenticatechallenge, falling back to the well-known path), RFC 8414 authorization-server metadata, RFC 7591 dynamic client registration — then an authorization-code + PKCE login with the RFC 8707resourceindicator. Tokens are stored inauth.jsonunder anmcp:<server>key, bound to the server URL they were minted for, refreshed proactively before expiry and silently on a 401.-
fir never opens a browser on its own — the MCP manager holds no login-UI hook at all, so no reconnect cycle or background goroutine can start one. A server that needs a login reports
run: /mcp login <server> (or: fir mcp login <server>). Log in withfir mcp login <server>from a terminal, or/mcp login <server>inside a session (TUI or ACP — over ACP a typed/mcp loginonly completes if the browser is on the agent host; ACP clients/relays useauthenticatemethodIdmcp:<server>with_meta.auth.{interactive,sessionId}, a two-call URL-out/paste-back flow documented indocs/acp-interactive-auth.md);fir mcp logout <server>//mcp logout <server>removes stored credentials;fir mcp listshows each server's auth state. After logging in out-of-band,/mcp reload [<server>]re-reads configs and credentials and reconnects servers that are down, so their tools load without a new session. Per-server health (ok | connecting | needs_auth | token_expired | error | disabled, config source, last error, tool count, fix) is inagent_introspectundermcp.serversand pushed to extensions as themcp_stateevent. Set"disabled": trueon a server to keep it configured but never connect. -
Credentials never ride plaintext: a non-
https://server URL is rejected unless the host is loopback (127.0.0.1,::1,localhost). Use"auth": {"mode": "none"}for a plain-HTTP server that needs no credentials. -
Optional
authoverrides (all absent by default — a spec-compliant server needs none):{ "mcpServers": { "pat-server": {"transport": "streamable", "url": "https://a.example/mcp", "auth": {"token": "${MY_SERVER_PAT}"}}, "no-dcr": {"transport": "streamable", "url": "https://b.example/mcp", "auth": {"client_id": "abc123", "scopes": ["mcp:read"]}}, "no-auth": {"transport": "streamable", "url": "https://c.example/mcp", "auth": {"mode": "none"}}, "bad-metadata":{"transport": "streamable", "url": "https://d.example/mcp", "auth": {"authorization_servers": ["https://login.d.example"], "scopes": ["mcp:read"]}}, "corp": {"transport": "streamable", "url": "https://mcp.corp.internal/mcp", "auth": {"allow_private_network": true}}, "okta": {"transport": "streamable", "url": "https://e.example/mcp", "auth": {"client_id": "0oa1b2c3d4EXAMPLE", "redirect_uri": "http://localhost:8765/oauth/callback"}} } }Choosing a mode (the field reference has the exact semantics; this is when to reach for each):
- Leave
authout entirely — the right answer for almost every server. fir discovers and logs in on its own. - Add
tokenwhen the server issues personal access tokens and has no OAuth at all. Bearer mode is inferred; always use${VAR}rather than pasting a secret into the file. - Add
client_id(+scopes) when login fails at the registration step — the authorization server does not do RFC 7591 dynamic registration. - Add
authorization_serverswhen login fails at the discovery step — the server publishes no RFC 9728 metadata, or publishes wrong metadata. - Add
redirect_uriwhen login reaches the authorization server but it rejects the callback — the issuer demands an exactly pre-registered redirect and ignores RFC 8252 §7.3 port variance (Okta is the canonical case). Pair it withclient_id. - Add
allow_private_networkwhen discovery cannot even connect because the issuer is on an internal network behind a private DNS name. - Set
mode: "none"only to opt out deliberately — e.g. a plain-HTTP local server that needs no credentials.
Common failure modes:
run: /mcp login <server>— expected on first use. fir never opens a browser by itself; the user must runfir mcp login <server>or/mcp login <server>.- Credentials refused before any request — the server URL is not
https://and not loopback. fir will not send a token in cleartext; fix the URL, or usemode: "none"if there is genuinely nothing to protect. - A pinned
redirect_urithat "looks identical" but fails — it is compared literally, solocalhostand127.0.0.1are different values. Match what is registered character for character, and make sure the pinned port is free at login time. - Browser cannot reach the box's loopback (phone sign-in through a gateway)? Pin an
httpsredirect_uripluscallback_listen: "127.0.0.1:<port>", or setFIR_MCP_OAUTH_REDIRECT_BASE=<https base>so fir uses<base>/<P>/callbackwhile listening on127.0.0.1:<P>. The proxy must forward the path unchanged. The variable is ignored whenclient_idorredirect_uriis set. allow_private_networkset but still blocked — a private IP literal is rejected upstream with no opt-out. Use a DNS name with a real certificate.- OAuth-only keys in
bearer/nonemode are rejected loudly rather than ignored, because the OAuth chain never runs there; remove them or switch mode. - Stale credentials after changing
authorization_serversare invalidated automatically; tokens live inauth.jsonundermcp:<server>, andfir mcp logout <server>clears them.
- Leave
-
-
MCP inspection — use
/mcpto see all configured MCP servers with their connection status, transport, capabilities (resources, prompts), and a full list of exposed tools with descriptions. Use/mcp <name>for detailed info about a specific server. Use/mcp reloadto re-readmcp.jsonandmcp.d/*.jsonconfigs from disk and apply the diff to running servers without a full session reload; it reports collisions when multiple files define the same server name.
settings.json Reference
All fields are optional. Nested objects merge recursively; arrays and primitives from overrides win.
{
// Model defaults
"defaultProvider": "anthropic", // Provider name
"defaultModel": "claude-sonnet-4-20250514", // Model ID
// A pin here beats the provider's own default forever.
// /doctor warns when the provider has moved on to a
// newer generation of the same model line (never rewrites it).
"defaultThinkingLevel": "medium", // none, minimal, low, medium, high
// UI
"theme": "dark", // Theme name or path
"hideThinkingBlock": false, // Hide thinking blocks in output
"collapseChangelog": false, // Collapse changelog on startup
"turnCache1h": true, // Anthropic: cache each turn's user message for 1h (TUI/ACP; env FIR_TURN_CACHE_1H=0/1 overrides)
"doubleEscapeAction": "tree", // "tree" or "quit"
"autocompleteMaxVisible": 5, // Max autocomplete suggestions shown
"showHardwareCursor": false, // Show hardware cursor
// Shell
"shellPath": "/bin/bash", // Shell for bash tool
"shellCommandPrefix": "", // Prepended to every bash command
// Compaction
"compaction": {
"enabled": true, // Auto-compact when context is large
"reserveTokens": 16384, // Tokens reserved for compaction summary
"keepRecentTokens": 20000, // Recent tokens to keep uncompacted
"maxContextTokens": 0 // Hard token cap — compact when exceeded (0 = disabled)
},
// Branch summary
"branchSummary": {
"reserveTokens": 16384 // Tokens reserved for branch summaries
},
// Retry on transient errors
"retry": {
"enabled": true,
"maxRetries": 3,
"baseDelayMs": 2000,
"maxDelayMs": 60000
},
// Thinking token budgets per level
"thinkingBudgets": {
"minimal": 1024,
"low": 4096,
"medium": 10240,
"high": 32768
},
// Terminal display
"terminal": {
"showImages": true, // Render images in terminal
"clearOnShrink": false // Clear screen when terminal shrinks
},
// Image handling
"images": {
"autoResize": true, // Auto-resize large images
"blockImages": false // Block all image content
},
// Markdown rendering
"markdown": {
"codeBlockIndent": " " // Indentation for code blocks
},
// Model filtering (glob patterns; empty = all)
"enabledModels": ["claude-*", "gpt-4*"],
// Extension/skill/theme allowlists (empty = all)
"extensions": [],
// Extension search directories (relative paths resolve against cwd)
// "extensionPaths": ["extensions"] → looks for ./extensions/ in every project
"extensionPaths": [],
// Additional resource lookup paths (resolved per-invocation relative to cwd)
// Use absolute paths, ~/... paths, or relative paths.
// Relative paths resolve against the working directory, making them portable:
// "skills": ["skills"] → looks for ./skills/ in every project you open
// "skills": ["~/shared-skills"] → always points to the same directory
"skills": [],
"themes": [],
"enableSkillCommands": true, // Allow /skills slash commands
"enableSysExtensions": true, // Allow extensions to prepend [SYS_EXT] context blocks
// Agent behavior
"steeringMode": "one-at-a-time", // "one-at-a-time" or "auto"
"followUpMode": "one-at-a-time", // "one-at-a-time" or "auto"
"transport": "sse", // Transport protocol
"serverTools": ["web_search"], // Anthropic server-side tools: "web_search", "web_fetch", "code_execution"
// You can also use raw types (e.g. "web_search_20260209"); model support is controlled per-model in models.json
"serverCompaction": { // Anthropic server-side context compaction
"enabled": true,
"triggerTokens": 150000, // When to trigger (min 50000, default 150000)
"instructions": "" // Custom summarization prompt (replaces default)
},
"debugLog": { // Debug log (debug.log) rotation
"maxSizeMB": 100, // Rotate when the file reaches this size
"keep": 20, // Number of compressed backups to retain
"compress": true, // gzip backups (debug.log.N.gz)
"checkEveryWrites": 1000, // Stat the file at most once per N writes
"checkEverySeconds": 30 // ...or once per this many seconds
},
"sessionStores": [ // Extra READ-ONLY session stores (global settings only;
{ "path": "~/fir-traces/zbox", // see "Session stores"). env FIR_SESSION_STORES replaces it.
"label": "zbox" } // label defaults to the dir name; "local" is reserved
],
"auth": { // Credential storage (global settings only; see "Secret storage")
"storage": "file" // file (default) | auto (OS secret store if it answers a probe,
// else auth.json) | keyring (require it). env FIR_AUTH_STORAGE overrides.
},
"mcp": { // MCP (Model Context Protocol) behaviour
"toolTimeoutSeconds": 120 // Default per-call timeout for model-dispatched MCP tools
// (default 120; <=0 disables; env FIR_MCP_TOOL_TIMEOUT overrides).
// Extension/pipe/wait calls (incl. timeout=-1) are never clipped.
},
// Internal (managed by fir)
"lastChangelogVersion": "", // Last changelog version shown to user
"packages": [] // Package metadata
}
04 同仓库更多 Skills
wt
Spawn a fresh fir agent in its own tmux session on its own git worktree. Use whenever the user wants the work — or the conversation about the work — to happen over there, not here. If the user points at this skill, or has directly loaded it in the context, that alone is the cue — spawn, don't inline.
wrap-up
Wrap up work on a project branch — review, fix, and simplify in a loop until clean, then ff-merge to main.
tmux-observer
Attach a tmux window to an already-running fir session — observe its transcript and drive its input via tmux send-keys. Use when you need to watch or steer another fir agent.
tmux-driver
Drive interactive CLIs (python, gdb, etc.) via named tmux sessions with multi-window support. Falls back to fir's built-in PTY driver when tmux is unavailable.
telegram-bot-setup
Set up a new Telegram bot channel for a fir project — install the telegram MCP, create a per-project state dir, drop in the bot token, wire .fir/mcp.json with TELEGRAM_STATE_DIR, pair the user, and verify. Use when the user asks to add, configure, or onboard a Telegram bot for a fir worktree/project.
sync
Sync with an upstream source repo — detect changed files, apply equivalent downstream edits, update the baseline, and report any new features or non-trivial changes picked up.