Skip to content

Manifest reference

Every field in the apiVersion: felix/v1 manifest schema. Source of truth: packages/harness/src/felix/manifests/schema.py, plus the compile-time rules in manifests/governance.py and the binders in manifests/builder.py.

Every model is Pydantic v2 with extra="forbid", so an unknown key is a hard parse error — not a warning. Where a field has a default, the default is what you get if you omit it.

apiVersion: felix/v1 # default; only this exact value is accepted
kind: Agent # default; only this exact value is accepted
metadata: { ... } # required
spec: { ... } # defaults to a minimal react agent

Validate one without starting the stack:

uv run felix validate-manifest manifests/quick.yaml
uv run felix validate-manifest manifests/governed.yaml -e production # applies production rules
Field Type Default Notes
name string, 1–128 chars, ^[a-zA-Z0-9._-]+$ required The manifest id, the OpenAI model value, the audit manifest_id, and an object-store key segment. No slashes or whitespace, so it cannot escape its key prefix.
version string "1.0.0" Free-form. Distinct from the tenant store’s monotonic version number.
description string "" Surfaced on GET /v1/models and the agent card.
tags string[] [] Free-form labels.
starters {title, prompt}[], at most 8 [] Prompts a client offers on an empty thread; prompt is sent verbatim when chosen (chat-ui sends it on click; the terminal client’s /start <n> puts it in the composer first). title is 1–60 chars, prompt 1–2000. Listed on GET /v1/models as felix.starters. Presentation only — nothing reads it to decide what a run may do.
greeting {headline, subtitle?} none What a client says on an empty thread, in place of its own default. headline is 1–80 chars, subtitle 1–300; both plain text, and written for any client rather than one UI. Listed on GET /v1/models as felix.greeting, null when not declared.
metadata:
name: cowork
greeting:
headline: What should we do in this folder?
subtitle: cowork reads your folder without asking, and asks before it changes a file or runs a command.
starters:
- title: Find TODOs
prompt: Search the workspace for TODO and summarize the matches by file.
spec:
pattern: react # default

Built-ins: react, deep, reflect, plan_execute (single-agent) and router, parallel, groupchat (multi-agent). The registry is open — register_pattern(name, builder) adds more, and nothing in core enumerates the list. See Patterns.

Multi-agent patterns require sub_agents and reject peers, containers, queues, sandboxes and browser_tools; a sub-agent’s own leaf manifest declares those instead.

spec:
model:
id: claude-sonnet
temperature: 0
max_tokens: 4096
thinking_level: medium
fallbacks: [claude-haiku]
Field Type Default Notes
id string | null null Logical route name. Null falls back to FELIX_DEFAULT_MODEL_ID (claude-sonnet).
temperature float 0.0
max_tokens int | null null Provider default when unset.
cache bool false Provider prompt caching.
thinking_budget int | null, 128–64000 null Extended-thinking token budget.
thinking_level off minimal low medium high xhigh max | null null When set, overrides thinking_budget via the level map.
fallbacks string[] [] Tried in order on a provider error; increments the felix_model_switch counter. The vision route tries the ones that can see. A turn a fallback answers is metered at the fallback’s rates.
vision_model string | null null Route for any call carrying an image when id is a text-only model. Null falls back to FELIX_DEFAULT_VISION_MODEL_ID; with neither, such a turn is a 422. See Images and text-only routes.
confidence_escalation object disabled See below.
price map[string, float] {} USD per 1M tokens, overriding the built-in table for cost attribution.

Logical ids resolve through DEFAULT_MODEL_ROUTES, overridable with FELIX_MODEL_ROUTES: claude-opus, claude-sonnet, claude-haiku, claude-fable, gpt-4.1, gpt-4.1-mini, and on Workers AI glm-5.3-cf, glm-5.3-flash-cf, deepseek-v4-pro-cf, deepseek-v4-flash-cf, qwen3.8-27b-cf, kimi-k2.7-code-cf, kimi-k2-cf, gpt-oss-120b-cf, gpt-oss-20b-cf, glm-flash-cf. The suffixed claude-sonnet-4 and claude-haiku-4 are retained as legacy aliases onto the current model in the same tier, and llama-3-pro / llama-3-fast as aliases onto GLM-5.3 and GLM-5.3 Flash; prefer the current ids. Providers are anthropic, openai, workers_ai, groq, together, deepseek, cerebras, fireworks, openrouter, xai, mistral and google, plus any registered through register_model_provider. Note that limits.max_cost_usd is refused at compile when the resolved model has no known rates — supply them with spec.model.price. See Model client.

confidence_escalation retries with a stronger model when the answer looks hedged:

Field Type Default
enabled bool false
escalate_to string ""
low_confidence_markers string[] ["i am not sure", "i don't know", "i cannot answer", "unclear", "uncertain", "no information"]
min_response_chars int ≥ 0 40
decider bool false

With decider: true the markers and length are not consulted: spec.decider is asked whether the reply fully answers the request, and a probability below spec.decider.min_confidence escalates. The heuristic stays as the fallback when the decider errors, and for side requests such as compaction summaries. Honoured on react and deep only — elsewhere it is refused at validation.

spec:
system_prompt:
inline: |
You are a support agent. Cite the knowledge base.
soul: false
base: ""
Field Type Default Notes
inline string "" Written in the manifest.
soul bool false Prepend the tenant’s shared “soul” prompt.
base string "" Shared preamble.
files string[] [] Keys loaded after base/inline and appended.
system_md string | null null Key loaded as a full replacement (SYSTEM.md semantics).
append_system_md string | null null Key appended after the composed prompt.
include_tool_guidance bool true Append the tool guidance section. false for a prompt that must be exactly what was written.

Parts are joined with "\n\n---\n\n" in the order soul → base → inline, then file-sourced parts. Empty parts are dropped.

Named user-message templates, expanded with $1 / $@ / ${1:-default}. A chat request runs one by passing template and template_args, or a person runs one as a slash command.

spec:
prompts:
- name: triage
description: Triage a ticket
argument_hint: "<ticket> [priority]"
body: "Triage this ticket: $1. Priority hint: ${2:-normal}"
Field Type Default Notes
name string, 1–64 chars required A slash command too when it is letters, digits, - and _ only.
body string ""
file string | null null Object-store / workspace key; used when body is empty.
description string | null, ≤200 chars null One line a client shows beside the command.
argument_hint string | null, ≤80 chars null How a client hints the arguments after the name.

A user turn on POST /chat, /chat/stream or /v1/chat/completions whose text is /triage FEL-12 high is replaced, before screening and hooks see it, by the expansion of triage with ["FEL-12", "high"]. Arguments split like a shell’s, so "two words" is one; an unclosed quote falls back to splitting on whitespace. The name matches regardless of case.

Only a declared name is a command. /etc/hosts is gone or /shrug on an agent with no such prompt is sent as written, and an agent with no spec.prompts behaves exactly as before. A command whose prompt expands to nothing is 404 empty_prompt:<name>. A turn sent as content parts (text beside an image) is never a command.

Every user turn in the request is expanded, not only the newest, so a /v1 client resending its history shows the model each earlier command as it was answered. One request may expand at most 16 (404 too_many_commands:16), and arguments longer than 16,000 characters split on whitespace only.

GET /v1/models lists each agent’s commands as felix.commands: [{name, description, argumentHint}], empty when there are none, and only the names a person can type. A client that has slash commands of its own should send a name it does not handle itself through to the harness when it is on that list.

How the agent writes its answers, picked per conversation. Each style is a block of prose that joins the end of the system prompt of the agent the person is talking to.

spec:
output_styles:
default: concise
styles:
- name: concise
description: Short, direct answers
prompt: Answer in as few sentences as the question allows. No preamble.
- name: teach
description: Explain as you go
prompt: Explain each step and why it matters, for someone learning the codebase.
Field Type Default Notes
default string | null null A declared style’s name. null leaves the system prompt as written.
styles list, at most 16 [] Names are unique, ignoring case.
styles[].name string required Letters, digits, - and _, up to 64.
styles[].description string | null, ≤200 chars null One line a client shows in its style picker.
styles[].prompt string, 1–16,000 chars required Inline only: a style cannot come from a file, since a workspace file is one the agent may write.

A conversation picks one with POST /chat/style, from its next run; null returns it to default. The choice is stored on the thread (outputStyle on the session snapshot), so it holds across turns until changed, and is not carried into a fork. If the thread later runs a manifest that does not declare that style, the run uses that manifest’s default.

The style goes at the end of the system prompt, after append_system_md (a transparency notice stays at the top), so the cached prompt prefix stays whole until the style changes. Sub-agents and task children keep their own prompts, background ones included: they answer their parent, not the person. Only a pattern whose own prompt writes the answer (react, deep) accepts the field; on a router the chosen child writes it, so the manifest is refused at compile rather than storing a style that changes nothing. GET /v1/models lists the styles as felix.outputStyles ([{name, description}]) and felix.defaultOutputStyle.

In chat-ui a Style picker appears beside Thinking whenever the selected agent declares styles, with default first (its description names the style it is); in the terminal, /style lists them and /style <name> (or /style default) sets one.

Which hosts this agent’s tools may reach. Unset, it is any public address the egress guard allows, as before; set, it is these and nothing else.

spec:
network:
allow_hosts: [api.github.com, "*.githubusercontent.com", docs.example.com]
Field Type Default Notes
allow_hosts string[] | null, at most 64 null null is unrestricted; [] allows nothing.
credentials string[], at most 64 [] Ids of operator-registered credentials this agent’s http_tools may send. Needs allow_hosts, naming each credential’s host.

Each entry is a host (api.github.com), *. and a host (*.github.com matches any name under it, never github.com itself), or an IP address. No scheme, port or path. Write an internationalised name in punycode (xn--mnchen-3ya.de).

  • http_tools refuse a URL on any other host, and the same check runs on every redirect hop: http_fetch_error: egress_blocked: destination not permitted.
  • browser_tools cannot be combined with it. A page reaches hosts through channels the browser’s request interceptor never sees, so a browser held to a list would look confined and not be: the manifest is refused when written, and a child with a browser under a parent’s list is refused at compile.
  • URLs the manifest names must be on the list: mcp_servers[].url, peers[].url, a container gateway_url, and an http_tools path_prefix. A manifest naming one that is not is refused when it is written.
  • Child agents (sub-agents and delegation children, background ones included) answer to this list as well as their own, so delegating cannot widen what an agent reaches.
  • A2A peers are pinned. With a list set, a peer__* tool has no manifest argument: the agent answering on the far side is the peer’s default, not one the model picks.

With credentials: [github], a fetch to the operator’s registered host and path carries the registered header — Authorization: Bearer … — which the harness adds as the request leaves. The model never sees the key: not in its tools’ arguments, the URL, the result or a log. A server that echoes the header back is shown to the model as [REDACTED].

spec:
network:
allow_hosts: [api.github.com]
credentials: [github]
http_tools:
- name: github_api
path_prefix: https://api.github.com/repos/acme/

The header goes only over https, only to that exact host and port, only under the credential’s path prefix, and each redirect hop is judged again: a hop to another host goes without it. An id the operator has not registered for your tenant, or whose host allow_hosts does not name exactly, fails the compile. Child agents do not inherit a parent’s credentials.

The list bounds the requests Felix sends for the model. It does not reach a process that opens its own connections — shell_tools, a sandbox, a stdio MCP server — nor what a listed MCP server or A2A peer fetches on its side, nor tools a plugin binds. Hooks, webhooks, push and model calls are the operator’s traffic, not the agent’s.

On a host many people publish to — *.github.io, *.vercel.app, docs.google.com — a listed host still admits pages anyone wrote. Pair it with an http_tools path_prefix there.

A list of built-in tool names.

spec:
tools: [calculator, read_file, write_file, edit_file, search_files, list_dir]
Tool Args Notes
calculator {expression} Safe expression evaluator.
list_dir {path} Max 500 entries.
read_file {path, offset, limit} Max 512 KB; flags binary files.
write_file {path, content, append} Max 512 KB. Like edit_file and multi_edit, refuses any path inside .git and any .gitattributes: they decide what git runs.
edit_file {path, old_string, new_string, replace_all} Replaces one exact string, leaving the rest of the file untouched. Max 4 MB file; refuses a match it cannot find, and a match it finds more than once unless replace_all is set; refuses when the resulting file would exceed the limit before writing it.
multi_edit {path, edits: [{old_string, new_string, replace_all}]} Up to 50 exact-string edits to one file, applied in order, each to the result of the one before. All land or none do: an edit edit_file would refuse refuses them all, and the file is written once, only if it has not changed since it was read. Max 512 KB file; use edit_file above that.
search_files {query, path, regex, max_hits} ≤ 50 hits; skips files over 256 KB.
glob {pattern, path, max_results} Finds files by name: * within a name, ** across folders (**/*.py, src/**/test_*.py), matched against the whole path under path, case-sensitive. Files only, never .git; up to 500 results, with truncated when there were more or the workspace was too large to walk whole. A path that does not exist or is a file is refused; so is a pattern with .., a leading /, more than 8 */? or more than 4 **.
list_skills / activate_skill / deactivate_skill / read_skill_file {name}; read_skill_file also {path} Bound to the real catalog when spec.skills or spec.skill_authoring.enabled is set; read_skill_file is added whenever any skill tool is.
ask_user {question, kind, options, timeout_seconds} Asks the person watching the run and waits for the answer. kind is confirm, select (implied by options) or input; the timeout defaults to 300 s, max 900. Returns {answered, value}, or {answered: false, reason}. Only a live /chat/stream can reach a person, so anywhere else it returns reason: "no_one_watching" at once.
todo_write {todos: [{content, status, active_form?}]} The agent’s checklist for a task with several steps. Each call replaces the whole list (at most 100 items); status is pending, in_progress (at most one) or completed. The session snapshot returns it as todos, read off the conversation’s current branch — so it survives a reload and follows a rewind or fork — and each write is announced on the stream as todo_updated. A write refused by governance changes nothing.
trigger_event none The event an inbound trigger started this run for: its type, delivery id and the payload as its sender posted it. Its output is third-party data, screened like an MCP server’s; outside a triggered run it returns an error. A trigger’s manifest must list it.

ask_user is how an agent puts a decision to a person instead of guessing: both web clients draw the question as a banner above the composer and post the answer back. Use it for choices that are genuinely the person’s to make. A question nobody can see is no question at all, so the tool refuses on a non-streaming POST /chat, a durable run, /v1 and A2A rather than holding the run for its whole timeout.

edit_file reads and writes bytes, not text — a universal-newline read would turn every \r\n in a file into \n, and writing the result back would rewrite every line ending in a CRLF file the edit never touched. Use it instead of write_file on any file the model did not just create: it is the only one of the two that cannot lose a byte the model failed to reproduce.

The five workspace tools resolve every path under the manifest’s workspace scope (spec.workspace), a directory under FELIX_WORKSPACE_ROOT, rejecting absolute paths and any traversal that escapes it. With the variable unset they fail with workspace_root is not configured (set FELIX_WORKSPACE_ROOT).

They never follow a symlink. Each path is opened one component at a time from the root with O_NOFOLLOW, so a link anywhere in it — a directory or the file itself — is refused (symlinks are not followed in workspace paths), and a FELIX_WORKSPACE_ROOT that is itself a symlink is refused. Checking a path and then opening it by name would let anything else writing the workspace swap a directory for a link between the two. list_dir reports a link as symlink; search_files does not descend into one.

Patterns contribute their own tools: plan_create / plan_update_step / plan_get (deep), and remember_procedure when procedural memory is on.

Which directory the workspace tools, shell_tools, an image tool’s path and publish_commits work in. Each is a directory under FELIX_WORKSPACE_ROOT:

workspace:
scope: thread # thread (default) | tenant | deployment
scope Directory For
thread <root>/.felix-scopes/<tenant>/<hash of tenant and thread> a conversation’s files are its own
tenant <root>/.felix-scopes/<tenant>/shared agents meant to share one workspace across a tenant’s threads
deployment <root> itself the self-build manifests (contributor, triage), whose root is a real checkout
  • No thread, no thread workspace. A call that carries no thread — an MCP or A2A call that names none — is refused under scope: thread rather than given a shared directory.
  • deployment is the operator’s. The root holds every other scope, so it is honoured only for the tenants in FELIX_WORKSPACE_DEPLOYMENT_TENANTS (comma-separated, default default). Any other tenant running a manifest that asks for it is refused, never given the root.
  • The scope is set per call, outermost, so an approval’s preview reads the same directory the call will write.
  • A thread with its own repository (POST /chat/sessions/{thread}/workspace/repo) works in that checkout instead, whatever the scope.
  • Instruction files (AGENTS.md, SYSTEM.md) are read from the root, which only the deployment scope can write.

On the default local backend, scopes keep one tenant’s paths out of another’s. They do not separate processes: every tenant’s tools run as the same user on the same host, so code shell_tools runs can reach past the path checks. The hosted backend separates them: each scope gets its own sandbox, and shell_tools run there. A deployment that admits tenants it does not trust binds shell_tools only under hosted.

Upgrading from a release before scopes. Files agents wrote at the root are visible only to the deployment scope afterwards. Move them, once, into the default tenant’s shared scope where the workspace is mounted — --keep leaves a name at the root, and nothing is overwritten:

docker compose exec api felix workspace migrate --dry-run
docker compose exec api felix workspace migrate --keep AGENTS.md

One line of advice per tool, keyed by tool name or glob, appended to the system prompt under a Tool guidance heading — only for tools the agent actually has once the manifest compiles.

spec:
tool_guidance:
search: Use `search` to find sources; a result is a title, URL and snippet, not the page.
fetch: Use `fetch` to read a page before relying on it, and quote what the page says.
"github__*": Read an issue's comments before proposing a change to it.

Advice written into system_prompt about a tool outlives the tool: remove fetch and the prompt goes on recommending it. Keyed by tool, the advice goes with the binding. It works for any tool the manifest binds — built-ins, integration tools, MCP tools by server__tool name or glob. Each line is at most 500 characters; identical lines appear once. A key that matches no bound tool is counted as felix_rule_targets_nothing, like the other tool lists. Tools defined in code (plugins) can carry their own line, which is listed alongside. Set system_prompt.include_tool_guidance: false to leave the section out.

spec:
skills:
- name: calculator-help
Field Type Default
name string required
version string | null null
description string | null null

A skill is a SKILL.md with YAML frontmatter and a Markdown body. The system prompt carries the catalog — each skill’s name and description — and activate_skill returns the body to the model as instructions. description here is not read by the loader (the SKILL.md’s own frontmatter supplies the catalog’s); it is surfaced on the A2A agent card. version pins an operator upload at skills/{tenant}/{name}/{version}/SKILL.md, which then wins over a tenant library skill of the same name; see Concepts → Skill for the full precedence.

Field Type Default Notes
spec.skills_declared_only bool false false seeds the catalog with every bundled skill, every skill in FELIX_SKILLS_DIR and every live tenant-library skill, and spec.skills adds to that. true makes the declared names the whole catalog.
spec.personal_skills off | read | write off read offers each authenticated caller the live skills in their own personal library, ahead of the tenant’s (see where skills come from). A caller’s skill never replaces one the manifest names in spec.skills. A durable run keeps the library of the caller who started it. write reads as read does and points the authoring tools at the caller’s library; it needs skill_authoring.enabled. Anything but off is refused together with skills_declared_only: true.

Lets the agent write to its tenant’s skill library: binds create_skill, update_skill and submit_skill_feedback. Off by default.

spec:
skill_authoring:
enabled: true
mode: draft
max_pending: 20
auto_eval: false
Field Type Default Meaning
enabled bool false Bind the three tools.
mode draft | publish draft draft: every save waits for a person to publish it. publish: a save is published at once if the publish gate passes, and stays a draft (the result carries publish_blocked with the reasons) if not.
max_pending int 1–200 20 Undecided agent drafts this manifest may hold in the tenant’s library, and undecided feedback it may have filed, before the tools refuse (pending_cap_reached, feedback_cap_reached).
auto_eval bool false Queue an evaluation (FELIX_SKILL_EVAL_MODEL) of every draft the agent saves; the result carries its eval_id. Model calls on the tenant’s bill.

With spec.personal_skills: write the tools save into the caller’s own library instead:

Tool Saves into
create_skill the caller’s library, always
update_skill the caller’s library when it holds the name (live, or a draft not yet in a catalog) and the manifest does not name it in spec.skills; the tenant’s otherwise
submit_skill_feedback the tenant’s library only; feedback on one of the caller’s own skills is refused (personal_skill)

A personal save needs a caller who has a library and holds the skills:personal scope, checked when the tool runs. Without either it is refused (no_personal_library, missing_scope) and nothing is saved; it is never redirected to the tenant’s library. The result carries library: "personal". max_pending and mode apply as for the tenant’s library, except that in publish mode a personal skill that would replace a tenant or host skill for its owner stays a draft for them to publish. auto_eval skips a personal save. An approval of the call binds the library it saves into, so a grant for one caller’s save authorizes none into another’s or the tenant’s. Under read or off the tools write the tenant’s library and refuse a name that is the caller’s own skill in the catalog (personal_skill).

mode: publish needs an approval on both writes. Validation refuses the manifest unless the approval rule spec.approvals would select for create_skill, and the one it would select for update_skill, both exist and carry no when_args — a conditional rule lets the calls without those arguments through ungated. Literal tool names win over globs and the last match wins, as for any approval. So a person reads every skill an agent publishes, in the approval preview the harness renders: the SKILL.md that would be saved and, for an edit, the parent version and every file kept from it by sha256.

spec:
skill_authoring:
enabled: true
mode: publish
approvals:
- id: skill-author
description: Confirm saving a skill to the library
tools: [create_skill, update_skill]
ttl_seconds: 600
allow_unattended: false

In either mode, an agent’s edit of a version an operator wrote is saved as a draft and never published automatically, and submit_skill_feedback only reaches library skills in this agent’s own catalog. Drafts and feedback are decided through /skill-library. The self-build contributor manifest enables authoring in draft mode; governed carries the skill-author approval rule above.

Each entry becomes one tool per remote tool, namespaced {name}__{tool}.

spec:
mcp_servers:
- name: notion
url: https://mcp.notion.example.com
transport: sse
auth: secret:NOTION_TOKEN
Field Type Default Notes
name string required Tool namespace.
url string "" Required for http / sse.
command string "" Required for stdio.
args string[] []
cwd string ""
env map[string,string] {}
auth string "" Literal token or secret:NAME.
transport http | sse | stdio sse
tools string[] [] Glob patterns over the remote tool names (issue_write, not github__issue_write). Empty binds every tool the server lists. Non-empty binds only matches, which is what lets an approval or policy rule enumerate a server’s write tools rather than gate whatever it served on the day; a pattern that matches nothing is logged at bind time, not refused.

Outbound URLs pass an SSRF guard. Parsing checks what needs no lookup — scheme, plain http outside development, internal names and suffixes, and IP literals including the decimal form. The address check happens when the connection is made: the harness resolves once, validates every returned address, and connects to one it validated, so the address that was checked is the address that is used. See Egress.

Each entry becomes a peer__{name} tool that calls a remote Felix over A2A.

spec:
peers:
- name: billing
url: https://billing.example.com
auth: secret:BILLING_TOKEN

name and url are required; auth accepts a literal or secret:NAME. Hop depth is bounded by limits.max_peer_hops.

An HTTPS container gateway, one tool per entry.

Field Type Default Notes
name string required
description string ""
gateway_url string required
image string required
container_tool_name string ""
timeout_ms int | null null 1–3,600,000 ms.
auth string "" Literal or secret:NAME.
args_schema JSON Schema | null null
fatal bool false When true, a failure aborts the tool batch.

Asynchronous work handed to a separate consumer over a Valkey/Redis list. The result lands back on the session later, so the run can continue without blocking.

spec:
queues:
- name: reindex
queue_binding: felix-jobs
deadline_ms: 600000
Field Type Default Notes
name string required
description string ""
queue_binding string required The Redis list key the executor pushes to.
deadline_ms int | null null
args_schema JSON Schema | null null
fatal bool false

The consumer writes the result back with POST /internal/sessions/{session_id}/events, which requires the X-Felix-Consumer-Secret header.

Container-backed code execution (the sandbox extra, Docker).

Field Type Default
name string required
description string ""
binding string required
sandbox_tool_name string ""
timeout_ms int | null null
path_prefix string ""
args_schema JSON Schema | null null
fatal bool false

Exec an allowlisted argv in the manifest’s workspace scope (spec.workspace) — the tool that lets a coding agent run the repository’s own gates. No shell interpreter: &&, | and ; are literal arguments. It runs as a child of the API process, or in a separate shell runner when FELIX_SHELL_RUNNER_URL is set (below).

spec:
shell_tools:
- name: run
commands: [uv run ruff, ./scripts/test.sh, git status, git diff]
timeout_ms: 300000
Field Type Default Notes
name string required
description string "" Defaults to a sentence naming the allowed prefixes.
commands string[] required argv prefixes, matched token for token from argv[0]. git status covers git status --short, not git push and not git -c x status.
timeout_ms int | null null Default 300 s; the process is killed at the limit.
fatal bool false
background bool false Offer run_in_background, and bind shell_output and shell_kill. See background commands.

The tool takes argv (list), cwd (relative to the workspace root) and optional stdin, and returns exit_code, stdout, stderr (each capped and marked truncated), timed_out and duration_ms. argv is command-screened like a sandbox payload. The web UI’s tool card recognises that shape and leads with the exit status, drawing the two streams as streams rather than as one JSON object.

A dev server, a file watcher or a long build should not hold the turn. A shell tool with background: true takes one more argument, run_in_background. The command starts, and the call answers at once with an id and status: running. Two tools are bound beside it, once however many shell tools set background:

Tool Arguments What it does
shell_output id, optional wait_ms (≤ 20,000) The command’s status, and what it wrote to each stream since the last read: at most the last 64 KB of each, with stdout_skipped / stderr_skipped counting what was passed over. While the command runs, waits up to wait_ms for something new. Without id, lists every background command in this workspace scope. Read-only.
shell_kill id Ends the command and every process it started, and answers its status.

status is running, exited (with exit_code), killed, timed_out, output_exceeded, failed (it could not start; error says why) or lost (its supervisor is gone, so nothing will end or report it, and it no longer counts against the limits below).

  • The same gates. A background start passes the manifest’s and the operator’s allowlists and the command screening a foreground call does, and runs in the same place: the API process on a development box, the shell runner, or the hosted workspace’s sandbox. Off a development box it is refused in the API, as a foreground call is.
  • Bounded. A command still ends at the tool’s timeout_ms, and when its two streams pass the foreground’s 8 MB budget. At most 4 run at once per workspace scope, 8 per tenant across its scopes, and 32 on the host that runs them; a start past any of them is refused with a message saying which. A finished command is kept for an hour, and at most 16 are kept per scope.
  • Reads take turns. Two shell_output calls on one command never return the same output: each byte goes to one reader.
  • Per scope. An id is only found in the workspace scope that started it: one thread’s tools cannot read or end another’s command. The commands themselves are another matter: where scopes share a host they run as one user and can reach each other’s state, as they can each other’s files — see deploy/GOVERNANCE.md, “Background commands”, in the harness repository.
  • Outside the workspace. Each command runs under a small supervisor, detached from the call that started it, that writes its output to a private directory under the host’s temp directory. The model’s file tools never see those files. A restart of the API or runner process leaves the command running; a restart of its container or host ends it, after which it reads lost or is no longer found.
  • On the hosted backend, the command lives in the scope’s sandbox, which stops after 10 minutes with no workspace call. A shell_output poll counts as one, so polling keeps the sandbox and its command alive; an unpolled command ends when the sandbox stops.
spec:
shell_tools:
- name: dev
commands: [npm run dev, npm test]
timeout_ms: 3600000 # a dev server for up to an hour
background: true

The shell runner. felix-shell-runner executes shell tools in a container of its own. With FELIX_SHELL_RUNNER_URL set, the API runs every check above, then sends the argv to the runner; the runner checks the argv against its own FELIX_SHELL_ALLOWED_COMMANDS and the cwd against the same scope under its own FELIX_WORKSPACE_ROOT again (the API sends the scope, and the runner accepts only that shape; run the runner on the same release as the API), and kills the process group at the deadline or when the API goes away. A runner that is down, errors, times out or answers anything malformed fails the call — it never falls back to running the command in the API.

Setting Default Notes
FELIX_SHELL_RUNNER_URL "" Empty runs shell tools in the API process. Operator configuration only: no manifest or model can set it.
FELIX_SHELL_RUNNER_TOKEN "" Bearer shared by the API and the runner; 32+ characters, required when the URL is set.

The builder stack (make up-self) runs one: a shell service that shares only the workspace volume, holds no secrets, sits on its own network so it cannot reach Postgres or Valkey, and runs with cap_drop: ALL, no-new-privileges and a process limit. Workspace clone and fetch run there too, since git fetch runs hooks the agent can write. It still has outbound internet for git fetch and uv, so a builder host must not carry a cloud instance role.

Browser automation through the browser extra (Playwright). Install it explicitly — it is never in the default image.

Field Type Default
name string required
description string ""
binding string required
op content | links | snapshot | screenshot | pdf | json content
timeout_ms int | null null
path_prefix string ""
args_schema JSON Schema | null null
fatal bool false

screenshot returns the viewport as an image the model sees, not base64 text. It rides on the tool result and is stored in the attachment store under the request’s tenant, with the same quota and retention as an upload. A request keeps at most 16 tool images across every agent it runs, and 4 from any one call. The browser is untrusted, so under content_screening each screenshot is screened by content_screening.image_model. Without image_model every screenshot is quarantined, and the build logs a warning saying so: set image_model to let screenshots through. A screenshot over 600 KiB is reported by size instead. pdf is not an image either wire can show and is still returned inline as text, up to 32 KB.

Image operations the model can run on the images in its conversation, backed by Pillow. Install the image extra (uv sync --extra image, or FELIX_DOCKER_EXTRAS=image). Without it each tool still binds and answers with what to install.

spec:
image_tools:
- { name: list_images, op: list }
- { name: thumbnail_image, op: thumbnail }
- { name: crop_image, op: crop }
Field Type Default
name string required
description string a description of the op
op list | info | resize | crop | rotate | convert | thumbnail required
allow_path bool false
fatal bool false

One op per tool, so a manifest grants exactly the operations it names. A model sees an image’s pixels, not its file id, so every op except list takes image:

  • latest, the default: the newest image in the thread’s active branch;
  • #n, as a list tool numbers them;
  • a felix-file:// reference the thread holds.

A reference to any other upload is refused. A tool whose entry sets allow_path: true also takes path, a file in the manifest’s workspace scope (spec.workspace). Reading it is opt-in, as with read_file. The ops’ own arguments:

Op Arguments Returns
list none The thread’s images, newest last, as #n with where each came from
info — Format, size, mode and frame count, as text
resize width, height (either alone keeps the aspect ratio) The resized image
crop left, top, right, bottom in pixels The box
rotate degrees, counter-clockwise The rotated image, on a canvas grown to fit
convert format (png, jpeg, webp, gif), quality The re-encoded image
thumbnail max_size for the longest side (default 512) The shrunk image

Each result comes back as an image the model sees. It is stored under the request’s tenant, with the attachment quota and retention, and it counts against the request’s 16 tool images (charged before anything is written). Its felix-file:// reference is named in the reply, so the next call can work on it.

Bounds:

  • Only png, jpeg, gif and webp are parsed, as sniffed from the bytes, so no other Pillow decoder is reachable.
  • Input is read up to 20 MiB, and an image whose header claims more than 4096×4096 pixels is refused before decoding.
  • Output is at most 4096 px a side and is downscaled to fit the 600 KiB attachment limit.
  • At most two images are decoded at once per process.

Under content_screening, results are screened according to where their input came from:

  • with image_model, every result is screened like an untrusted tool’s image;
  • without it, a result made from a workspace file is quarantined, and one made from a thread image passes.

Search the web through the backend an operator configured. The model supplies a query, not a destination — the inverse of spec.http_tools below, and the reason this ref has no confinement field: the only address it reaches is FELIX_SEARCH_URL.

spec:
search_tools:
- name: search
description: Search the web for sources.
max_results: 8
content_screening:
enabled: true
Field Type Default
name string required
description string ""
max_results int | null null (5); 1–20
fatal bool false

Configure the backend with FELIX_SEARCH_BACKEND (default none) and FELIX_SEARCH_URL. SearXNG ships built in because it is self-hostable and needs no account; anything else — Brave, Tavily, an internal index — is a plugin registering through felix.search.register_search_backend, and the setting is validated against that registry at startup. FELIX_SEARCH_API_KEY is optional and resolves through your secrets backend like any other credential.

Results render as numbered title / url / snippet blocks, because the next thing an agent does with a result is fetch its URL. A search is replay-safe, unlike a fetch: the endpoint is yours and a query has no side effect.

Declaring one without a backend still binds the tool, and every call reports that FELIX_SEARCH_BACKEND is unset — rather than the field silently doing nothing. The compile logs a warning too.

Read a URL the model chooses. This is the inverse of an MCP server or an A2A peer, where the manifest fixes the destination — so path_prefix is how an author narrows it back down, and the caps live here rather than with the caller.

spec:
http_tools:
- name: fetch_docs
description: Read a page from the Felix documentation site.
path_prefix: https://docs.felix.run/
max_bytes: 60000
timeout_ms: 10000
content_screening:
enabled: true
Field Type Default
name string required
description string ""
path_prefix string "" — an absolute http(s) URL, normalised to end in /
allow_any_host bool false
timeout_ms int | null null (15,000 ms); 1–3,600,000 ms
max_bytes int | null null (100,000); up to 5,000,000
format text | raw text — renders HTML to readable text
fatal bool false

An entry must set either path_prefix or allow_any_host: true; a manifest that sets neither fails validation. Every other outbound ref names a destination you chose, so leaving this one open by default would make unrestricted egress the path of least resistance. allow_any_host is logged when the agent compiles.

The request is a GET. Redirects are followed up to five hops, and each hop is re-checked against both the egress guard and path_prefix — a redirect cannot leave the prefix. timeout_ms is a whole-call deadline covering the chain and every read, not a per-read timeout. The body is streamed and truncated at max_bytes, counted after decompression. A response that is not textual is described rather than decoded, and a non-2xx keeps its body, because the error message is usually the useful part.

Not replay-safe. A resumed run will not re-issue a fetch. The model names the endpoint, and a GET that changes something is ordinary on the open web.

Publish commits the agent already made in its workspace to GitHub, without the file contents passing through the model. Binds one tool, publish_commits.

spec:
github_publish:
repo: felix-run/felix
auth: secret:GITHUB_MCP_TOKEN
base: main
branch_prefix: felix/
approvals:
- id: publish
description: Publish these commits — read the diff in the preview
tools: [publish_commits]
one_shot: true
bind_principal: true
Field Type Default Notes
repo string required owner/name on github.com.
auth string required A secret:NAME ref, never a literal — the value is a write credential.
base string "main" The branch a new branch starts from, and one the tool never writes. Must not start with branch_prefix.
branch_prefix string required Every branch the tool may create or move starts with this.

The model calls publish_commits(branch, head_sha, title?): a branch under branch_prefix and the full sha of a commit it made locally. The harness reads those commits with read-only git and writes them through GitHub’s Git Data API — blobs, one tree, one commit, then the ref — so a 196 KiB file with a one-line change is never a tool argument.

  • An approval binds the content. head_sha is an argument and commits are content-addressed, so approving one sha does not approve another on the same branch. Every blob sha GitHub returns, and the final tree, are checked against the local ones before the ref moves.
  • It only fast-forwards. The new commit’s parent is the branch’s current remote tip (or base’s, for a new branch), which must be an ancestor of head_sha; the ref update is force: false. Several local commits publish as one commit listing their subjects.
  • The approval row shows the diff. The harness computes the diff stat and unified diff between that parent and head_sha and attaches it as the row’s preview (below); if it cannot, the call is refused rather than approved blind.
  • Bounds: 300 files and 8 MiB per publish, the preview’s diff capped at 32 KiB (the stat is never cut), 300 s per publish.
  • Where the token is. Felix uses it in one HTTP client and passes it to no git process or workspace environment. Where shell tools run in the API process — FELIX_SHELL_RUNNER_URL unset — code the agent runs can still read that process’s environment, token included; run them through a shell runner, as the builder stack does. Either way keep the token’s GitHub permissions to contents and pull requests, and its Workflows permission off.

Tools the browser or CLI client executes, not the server. This is how the web clients run local shell commands and open local files.

spec:
client_tools:
- name: local_shell
description: Run a shell command in the user's local environment.
args_schema:
type: object
properties:
command: { type: string }
cwd: { type: string }
required: [command]
additionalProperties: false
timeout_seconds: 300
Field Type Default Notes
name string required
description string ""
args_schema JSON Schema | null null
timeout_seconds float, 0 < x ≤ 3600 null → 120 s
fatal bool false

Both bundled clients answer seven file tools by name, against the folder the person has open — in chat-ui the mounted folder (or the tab’s own files when none is mounted), in the terminal client its working directory. Declare them under client_tools and the agent works on that folder rather than on the server’s workspace. The bundled cowork manifest does exactly this.

Name Arguments Answers
local_list path?, recursive? One path per line, directories ending in /. A recursive listing skips .git and node_modules, and stops at 1,000 entries, saying so.
local_read path, offset?, limit? The file’s text, 100,000 characters unless limit says otherwise, with a line naming the next offset when there is more.
local_search query, path?, regex?, max_hits? path:line:text per matching line, binaries and .git/node_modules skipped, 20 hits unless max_hits (at most 200) says otherwise.
local_write path, content, append? wrote N chars to <path>.
local_edit path, old_string, new_string, replace_all? {"path", "replacements"}, the shape edit_file reports. Refused when old_string is missing, or appears more than once without replace_all.
local_delete path {"path", "deleted": true}, the shape delete_file reports. Files only, never a folder; there is no undo.
local_rename path, to_path {"path", "to_path"}. Never overwrites: refused when to_path exists. Folders on the way to to_path are created.

The arguments are the workspace tools’ own, so the changes list and the approval diff treat both families alike. A failure comes back as error: …, which both clients draw as a failed call. Gate local_write, local_edit, local_delete and local_rename with an approval — a delete must never be less gated than a write. chat-ui acts once the banner is approved, and a delete’s banner shows the file it removes; the terminal client also asks on its own prompt, naming the absolute path, and refuses .git/, .husky/, node_modules/, executable files and symbolic links outright.

On a durable run the agent is in the worker, not the process serving the stream, so the harness records each pending request and the durable stream announces it as the same tool_request frame a streamed run sends. A harness older than that (felix-run/felix#513) cannot run client tools on a durable run at all: the request never reaches the client, and the call times out.

When the model calls one, the harness emits a tool_request frame and blocks until the client answers with POST /chat/tool_result. A client that never answers stalls the run until the timeout, which returns [error/timeout] client tool timed out as the tool result. See the streaming contract.

spec.sub_agents and spec.aggregator_prompt

Section titled “spec.sub_agents and spec.aggregator_prompt”

sub_agents is a list of manifest names, required by the multi-agent patterns and resolved at build time the way a request resolves a manifest — the tenant’s stored versions, then object storage, then bundled YAML. A name that resolves nowhere fails the compile; each child compiles once however many parents name it, nesting deeper than four routers is refused, and so is a cycle. aggregator_prompt (string, "") tells parallel and groupchat how to merge their results.

A child inherits the caller’s admission: spec.auth.inbound is checked on the manifest a request names, not again on each child. Put the admission you need on the router — a child’s own required_scopes apply when it is called by name, not when a router hands it a request. Everything else a child declares (tools, policies, approvals, screening, limits) is compiled into it and applies however it was reached. A router with spec.decider picks its child with the decider and falls back to its model classifier when the decider is unsure.

spec:
pattern: react
delegation:
agents:
- name: researcher # a manifest name, resolved like `sub_agents`
description: Looks things up and reports back with sources.
- name: reviewer
description: Reviews a draft against the style guide.

Binds a task tool: the model hands a self-contained job to one of agents and gets that agent’s final answer back as the tool result. Unlike sub_agents, which make the agent a composite with no tools of its own, a delegating agent keeps its tools and decides at run time whether to delegate. description is what the model reads to choose; names must be unique.

  • Fresh context. The child starts with its own system prompt and only the prompt the model wrote — none of the parent’s conversation — so a long search spends the child’s context and the parent keeps one tool result.
  • Resolved and pinned like a sub-agent. Children resolve through the tenant’s store, then object storage, then bundled YAML. A name that resolves nowhere, a cycle, or nesting deeper than four fails the compile, and pin_compile covers the children.
  • The child’s own door. Each call checks the child’s spec.auth.inbound against the caller — anonymity, schemes, required_scopes — so a caller who could not call the child by name cannot reach it through task, however the model was steered. A refusal is a tool error the model sees; the child never runs. (A router’s children inherit its admission instead; there the caller chose the route, not the model.)
  • Governed twice. task passes the parent’s governance stack like any tool — a policy, approval or screening rule can name it — and each child runs its own manifest’s stack on its own tools, including inbound screening of the prompt it is handed. The child’s answer is treated as untrusted output by the parent’s content screening, as a peer’s reply is.
  • One budget, every cap. The child spends the parent run’s counters, and is held to the parent’s limits as well as its own: a lead capped at max_cost_usd: 0.50 cannot buy more by delegating to an uncapped child. Every task call counts against max_peer_hops, shared with peers.

Background children. With background: true (and optionally max_background) beside agents, the model may pass background: true to task: the child starts as a durable run and task returns a task id at once, and a second tool, task_result(task_id, wait_seconds), reads its status and — once it is done — its answer, waiting up to 60 seconds. The child runs in felix-worker (so the worker and felix-scheduler must be running), on a thread of its own linked to the parent’s, as the caller who started it, and pinned to the manifest the parent compiled — a child edited before the worker picks it up does not run. It expires no later than the token or run that started it. Since the parent’s run may end first, the child cannot share its counters; instead it runs on what is left of every budget above it when it starts — a lead capped at ten tool calls that has made four hands its background child six. A background child cannot start background children of its own (foreground delegation still nests), and a conversation may have at most max_background (default 3, at most 16) in flight at once — the next start is refused until one ends, which is what bounds a thread’s total exposure across turns. task_result reads only tasks its own thread started.

Children in a repository. In a thread with a repository of its own, a child can work in a git worktree of it: its own working tree, on its own branch (felix/task/<id>), sharing the repository’s history. What it commits is in the thread’s repository at once (git log felix/task/<id>, git merge), and the parent’s working tree is untouched while it works.

  • A background child always does. On a thread of its own, it would otherwise have no copy of the repository at all. The start message names its branch.
  • A foreground child does under worktree: true. Without it, a foreground child edits the thread’s checkout directly, which suits a helper whose edits the parent should see at once. Set it for children that change code side by side, so two of them never write the same files. The child keeps the parent’s run: its budgets, its approvals, its stream. When it finishes, the parent is told the branch, how many commits it made, and whether it left changes it never committed (which only its worktree holds). A worktree it left nothing in is removed with its branch. In plan mode a foreground child is read-only and gets no worktree.
  • Refused rather than misplaced. A thread whose repository is still cloning, failed or expired starts no child; neither does a repository whose git configuration names a filter driver (checked from inside the new worktree, includes resolved), since the checkout would run it. A thread holds at most eight task worktrees holding work; past that, a task call that needs one is refused until one expires. A thread with no repository delegates exactly as before.
  • Local checkouts only. Under the hosted workspace backend each thread’s repository is in its own sandbox, and a worktree cannot span two. There a background child is told it has no copy of the repository, and a foreground child shares the thread’s.
  • Lifetime. When a child’s run ends, foreground or background, a worktree it left nothing in is removed with its branch. Any other is removed with its thread’s checkout, or after FELIX_REPO_CHECKOUT_TTL_DAYS unused, and using it keeps the parent’s checkout from expiring. Its branch and commits stay in the repository. Removing the parent thread’s checkout marks its children’s worktrees failed. A child’s own children branch from the child’s worktree.
  • Publishing is the parent’s. publish_commits from a child’s worktree is refused: its commits are already in the parent’s repository, and the parent publishes them.
spec:
delegation:
worktree: true # foreground children get worktrees too
background: true
agents:
- name: coder
description: Implements one change on its own branch.

Several task calls in one turn run side by side when spec.execution.tools is parallel. delegation and sub_agents on the same manifest are refused: the composite pattern would never offer the tool to a model. For an agent on another deployment, use spec.peers.

spec:
max_turns: 4 # rounds for the multi-agent patterns; default 4, ceiling 100
recursion_limit: 10 # steps for a react loop; default 10, ceiling 50

They bound different loops. max_turns is read by groupchat, plan_execute and the other delegating patterns. A react agent — the default pattern — is bounded by recursion_limit, where one step is a model call plus the tool batch it asked for; max_turns on a react agent bounds nothing, and the compile step warns (felix_rule_targets_nothing{kind="max_turns"}). A react loop that runs out of steps with tool calls still pending ends with stop_reason: max_turns, a truncated session status, and felix_run_stop_reason{reason="max_turns"}.

spec:
memory:
checkpointer: postgres # default; `none` also built in
store: pgvector # default

There is no in-process built-in, on purpose. A thread is not manifest-scoped — the session routes address one by id with no manifest in hand — so a manifest choosing a different backend would leave the agent reading one log while /history, /continue and /compact read another. none is exempt because it is a claim about the agent, not a competing backend.

Field Type Default Notes
checkpointer postgres | none, or a registered name postgres Where the session log lives. none keeps no session state, so every turn starts from the messages it was given. Add your own with register_checkpointer.
store pgvector | memory | none (aliases agentcore, vectorize accepted but inert) pgvector Durable-fact vector store.
capture object disabled Extract durable facts from a turn.
recall object tools off How stored facts reach the model beyond the prelude.
consolidate object disabled Periodically merge agent-written facts that say the same thing (below).

The bundled assistant manifest is the worked example: capture on claude-haiku with max_facts: 3 and min_chars: 200, recall.tools: true, content_screening on, an approval (memory-forget) on forget, and auth.inbound.allow_anonymous: false. Memory is keyed by tenant and manifest, not by caller, so a manifest that captures on a deployment shared by more than one person puts one caller’s facts in another’s prompt.

Capture only adds, so a pool of facts grows on every long turn. Consolidation merges the ones that say the same thing.

spec:
memory:
consolidate:
enabled: true
model: claude-haiku
after_facts: 50
max_facts: 200
Field Type Default Notes
enabled bool false Opt in, per manifest.
model string "claude-haiku" The route asked which facts are duplicates. Its calls are metered to the tenant and manifest.
after_facts int ≥ 10 50 Only a pool holding more than this many eligible facts is considered.
max_facts int 1–500 200 The newest facts shown per pass.

The worker’s consolidate_memory cron (every 15 minutes) shows the model the newest max_facts active facts of the pool and asks which state the same thing. What it is allowed to do is narrow on purpose:

  • It only merges duplicates. Facts that merely relate, or that disagree, are left as they are.
  • The model returns groups of ids, never text. The store keeps the oldest fact of each group — the model’s ordering is ignored — and supersedes the rest, which stay stored and auditable. No memory text is ever written by consolidation.
  • Only agent-written facts. Facts an operator wrote through /memory are never shown and never retired. Facts of a different kind, or a different topic_key (absent counts), are never merged.
  • Every group is checked first — ids from the batch shown, none in two groups — and is applied whole or not at all; a malformed answer changes nothing.
  • A pool that has not changed is not asked again, and at most 50 pools are consolidated per tick. The skip is remembered per worker process, so a restart costs one call per pool.

Facts reach the model fenced as untrusted data, since capture extracts them from turns that can include tool output.

capture:

Field Type Default
enabled bool false
model string "claude-haiku"
max_facts int, 1–20 5
min_chars int ≥ 0 80
verify bool false

verify adds a second pass that hands the proposed facts back to model along with the excerpt and keeps only what the excerpt actually supports. It doubles the extraction calls, which is why it is off by default — turn it on where stored memory is acted on rather than merely recalled.

The pass can only remove. Its verdict selects from the proposals; it never supplies a memory of its own, and it cannot alter a kept memory’s kind, topic_key or importance. That matters because the excerpt it reads is untrusted transcript. A captured topic_key cannot retire anything — only an operator retires a memory by its key, and a captured value is stored beside the one it contradicts — but a verdict that could set a key could still make its own memory the one the prelude shows as current.

It fails open on anything it cannot read, on the grounds that a broken verifier is not evidence the facts were wrong:

Verdict Result
A subset of the proposals Just that subset is stored
[] — a well-formed empty array Nothing is stored. The one unambiguous way to reject everything
The call errors The unverified set is kept
Unparseable, or every item unusable The unverified set is kept
Valid items that match no proposal The unverified set is kept — the verifier rewrote rather than chose

Content is matched case- and whitespace-insensitively but not punctuation-insensitively, so a verifier that echoes an item back with a trailing full stop counts as having rewritten it.

Extraction runs on model, not on the turn’s model — it is a small, mechanical job on every completed turn, and billing it to a frontier model roughly doubles the cost of having memory at all. If that model cannot be built, extraction falls back to the turn’s model rather than dropping the fact.

Facts land in memory_vectors (pgvector, HNSW) with one of four kinds: fact, event, instruction, task. The extractor asks the model to classify each fact, and anything outside that set is silently rewritten to fact rather than rejected — so a prompt that invents a kind degrades quietly instead of failing.

Stored facts reach the model two ways. The prelude — the active facts, which the ReAct loop renders as a message directly after the system prompt on each run — is built whenever store is pgvector or memory; it is escaped but bypasses the governance stack. recall:

Field Type Default Notes
tools bool false Bind remember, recall, forget and list_memories. They are bound before the governance stack, so recalled text passes through content screening, policies and approvals like any tool output. Not bound when store: none.
limit int, 1–50 5 Default number of hits recall returns.

Recall is hybrid: full text, topic keys, and a vector channel when FELIX_MEMORY_EMBEDDER resolves to an embedder. The default, auto, uses the local model when felix-harness[embeddings] is installed and skips the vector channel otherwise — see Embeddings.

Procedural memory is a separate subsystem under spec.procedural_memory, not a kind in this table.

spec:
session:
strategy: compacting
reserve_tokens: 16384
keep_recent_tokens: 20000
context_window_tokens: 128000 # optional: omit to use the model's window
Field Type Default Notes
strategy string "full_replay" full_replay, windowed:N, summarizing:N, semantic:N (needs embeddings), compacting.
compaction_enabled bool true
reserve_tokens int ≥ 0 16384 Headroom kept for the reply.
keep_recent_tokens int ≥ 0 20000 Never compacted.
context_window_tokens int ≥ 1024 | null null The window to compact against. Unset: the model’s own, from the catalog through its route (1M on a 1M-context model). Set it to compact earlier on purpose — a smaller window is a cheaper turn.
steering_mode all | one-at-a-time all
follow_up_mode all | one-at-a-time all How queued follow-ups are answered once the run has an answer: all gives every one waiting to a single further turn; one-at-a-time gives each its own. A follow-up’s turn is an ordinary step — tools run, it streams — and counts against recursion_limit; a run with no step left, or that stopped on a budget, truncation or abort, keeps them for the thread’s next run.
branch_summary bool true Summarize an abandoned branch on rewind.
compact_after_turn bool false
clear_tool_results {keep_turns, min_chars} | null null (off) Stub stale tool results in what the model is sent. See below.

The session log is append-only and lives outside the context window; the strategy decides what the model actually sees. See Persistence.

A long run piles up tool results — file reads, search hits, command output, screenshots — that the agent needed once and never reads again, and every later model call pays for them. clear_tool_results replaces each old one with a one-line stub in what the model is sent:

spec:
session:
clear_tool_results:
keep_turns: 10 # 1–1000
min_chars: 1000 # text results this size or smaller are kept, unless they carry an image
  • Which results. A tool result is stubbed when it sits behind the newest keep_turns user messages and is longer than min_chars or carries an image. The cut-off advances keep_turns user messages at a time, so between keep_turns and 2 × keep_turns − 1 of the newest are always whole. A steer or a compaction summary counts as a user message, which only ever keeps more.
  • Only the request changes. The session log keeps every result, so a rewind, fork, export or compaction summary still has them.
  • The call stays paired. Only the result’s content and images are replaced; the assistant’s tool call and the result’s id are kept, so every provider still sees a result for every call.
  • A spilled result keeps its handle. A result that spec.artifacts spilled names its artifact id in the stub, so read_artifact still reaches the full output.
  • The stub asks the model to re-run only a tool that reads, never to repeat an action to see what it returned. A result content screening replaced with a [quarantined] notice is never stubbed.
  • What the caller sent is left alone. A client that re-sends the whole conversation (the OpenAI-compatible /v1 surface) has its messages passed through as written.
  • Prompt caching. For full_replay and compacting, the cached prefix changes once every keep_turns user messages rather than on every call. windowed:N, summarizing:N and semantic:N change their prefix every turn anyway.
  • Clearing runs before replay image screening, so a stubbed screenshot is not screened again. POST /chat/ask reads the thread the same way its next turn would. A router’s setting also applies to the children it hands the thread to.

The compacting strategy measures the window before results are stubbed, so it can still compact when clearing alone would have made room.

spec:
auth:
inbound:
allow_anonymous: false
required_scopes: [chat:write]
outbound:
providers: []
Field Type Default Notes
inbound.schemes string[] [] Enforced. The scheme the caller authenticated with (api_key, or the JWT verifier scheme) must be listed; jwt is an umbrella for every configured JWT scheme. Mismatch → 403.
inbound.required_scopes string[] [] Enforced. Missing scopes → 403.
inbound.allow_anonymous bool false Enforced. Anonymous callers get 401 unless true.
outbound.providers string[] [] Enforced at compile. The resolved provider for the primary model and every fallback must be listed, or the build fails — not the first model call.
spec:
observability:
trace: true # default
Field Type Default Notes
a2a.publish bool true Opt-out. false withholds the agent from /.well-known/agent-card.json. The default is true because the field was never read before, so every agent was already advertised; a false default would have 404’d every existing manifest.
a2a.capabilities[] {id, description, input_schema_ref} [] Merged into the agent card, alongside spec.skills.
observability.trace bool true
observability.metrics[] string[] [] Empty = every counter. Non-empty is an allowlist: unlisted counter names are dropped before the series exists, which also bounds the Prometheus cardinality that tenant-supplied manifest ids and remote MCP tool names would otherwise create.
spec:
execution:
mode: durable # default: transient
tools: sequential # default
webhooks: [ops] # durable only; ids from FELIX_WEBHOOK_ENDPOINTS
Field Type Default Notes
mode transient | durable transient durable runs the invocation as a fiber.
resume_token_ttl_seconds int | null null → FELIX_HIBERNATE_AFTER_SECONDS (300)
tools parallel | sequential sequential Tool-call execution within a batch.
webhooks string[] (≤ 8) [] Endpoint ids announced to when the run ends. Needs mode: durable. Each must match ^[a-z0-9][a-z0-9_-]{0,63}$ and be registered in FELIX_WEBHOOK_ENDPOINTS for the caller’s tenant, or the run is refused with 422 unknown webhook endpoint: <id>.

A durable POST /chat returns 202 with {status, resume_token, fiber_id, expires_at, thread_id}; poll GET /chat/runs/{resume_token}. Fibers checkpoint in Postgres and are picked up by the worker within about a second (FELIX_FIBER_POLL_SECONDS, see Deploy → Durable runs). Fibers are the one durable backend; the Temporal backend was removed, and FELIX_DURABILITY=temporal now fails startup.

POST /chat/stream on a durable manifest streams the run instead — run_accepted, run_status, final — and a client that disconnects reattaches with the resume token (see REST API).

webhooks names endpoints, never URLs: the operator registers each one’s URL, signing secret and allowed tenants, and the worker POSTs the finished run to it. See Completion webhooks for the payload and signature, and Deploy for registering endpoints.

Selects a relevant subset of tools per turn instead of sending the whole catalogue.

Field Type Default
enabled bool false
top_k int ≥ 1 20
model string "bge-base-en-v1.5"
decider bool false

With decider: true, spec.decider picks the shortlist: one choice over the whole catalogue per user turn, the top_k most probable offered, tools already used this thread always kept. It falls back to the embedding (or keyword) ranking when the decider errors or when the shortlist holds less than spec.decider.min_confidence of the probability mass. Needs enabled.

A decision model answers typed questions — pick one option, score against levels, true or false — with calibrated probabilities instead of generating text. Naming one here does nothing on its own; each consumer opts in with its own flag, and each keeps its previous behaviour as the fallback.

spec:
decider:
id: clef # a FELIX_DECISION_ROUTES id
min_confidence: 0.5
Field Type Default Notes
id string "" Off when empty. clef and clef-flash (Cloudflare’s, on Workers AI), jev (TypeSafe) and jev-cf (Jev on Workers AI) are built in; an id not in FELIX_DECISION_ROUTES fails the compile.
min_confidence float 0–1 0.5 Below this a consumer treats the decision as unsure and takes its fallback.
Consumer Flag What the decider decides
Tool selection tools_retrieval.decider Which tools the model is offered this turn
Router pattern: router with decider.id set Which sub-agent answers
Escalation model.confidence_escalation.decider Whether the reply answers the request
Judges guardrails.judges[].decider Probability the text meets criteria
Reflect reflect.decider Whether a draft meets the criteria
Skill hints skill_suggestion.enabled Which skill fits a task, sent as a hint
Screening content_screening.decider Injection, jailbreak and exfiltration risk

Every decision is metered like a model turn and counts against limits.max_cost_usd. The request text — and, for the consumers that judge them, replies and tool output — goes to the decider’s provider unmasked. The bundled decider-support manifest switches on every consumer that fits a support agent. See Model client.

Ranks the skill catalogue with spec.decider, reranks a shortlist against each skill’s body (an imported skill’s listed description only, never its body; see Skill import), and — only when the request asks for a task and a skill fits it — adds a one-line hint naming that skill. The model still decides whether to activate_skill. The hint rides the turn’s first model call as a transient message: after the prompt-cache breakpoint, never in the session log.

Field Type Default Notes
enabled bool false Needs spec.decider.id; react and deep only.
shortlist int 1–10 3 Skills carried into the rerank; a catalogue no larger skips the ranking.
min_gate float 0–1 0.3 Probability the request asks for a task at all.
min_fit float 0–1 0.3 Probability the best skill does what was asked.

Spills oversized tool output to the object store, shows the model a preview, and gives it a tool to read the rest.

Field Type Default Meaning
enabled bool false Spill, and bind read_artifact
threshold_chars int ≥ 1 8000 A tool result longer than this is spilled
preview_chars int ≥ 1 200 How much of it stays in the transcript
default_window_chars int ≥ 1 4000 read_artifact window when the model gives no length
max_window_chars int ≥ 1 16000 Largest window one read_artifact call returns

Output over the threshold is written to artifacts/{tenant}/{manifest}/{uuid}.txt and replaced with the preview plus a marker, which is always the last thing in the output:

…[artifact:<id> key=artifacts/<tenant>/<manifest>/<id>.txt chars=<N> spilled_at=<unix>]

The model reads it back with read_artifact, bound automatically whenever enabled is true and an object store is configured:

Argument Type Default
artifact_id string — the id from the marker
offset int ≥ 0 0
length int ≥ 1 default_window_chars, capped at max_window_chars

Each window comes back headed [artifact-window:<id> chars <start>-<end> of <total>; continue at offset=<end>], or ; end] on the last one, so the model pages by the offset it was given.

  • It reads only what its own conversation spilled — the thread, or the request when there is no thread. The tenant and manifest prefix is shared by every caller of a manifest, so an id alone would otherwise reach another caller’s run. Another conversation’s id is refused exactly as a missing one is.
  • It is governed like any tool. It is bound before the governance stack, so policies, limits, screening and audit apply to a read as to the call that produced the artifact.
  • It is never itself spilled, although a window can be larger than threshold_chars.
  • Spill expires. The worker’s retention sweep deletes spills older than FELIX_ARTIFACT_RETENTION_DAYS (30 by default, 0 keeps forever); after that read_artifact reports the id as missing.

Clients read artifacts through GET /artifacts instead, which needs artifacts:read and is not held to a conversation. The manifests that read files or fetch pages — cowork, deep and support, and the self-build contributor and triage — enable artifacts.

Field Type Default
verifier_model string ""
threshold float 0–1 0.7
max_iterations int 1–5 2
criteria string ""
decider bool false

decider: true verifies each draft with spec.decider instead of asking verifier_model for a bare number; the model verifier and the heuristic remain the fallback.

Field Type Default
planner_model string ""
executor_model string ""
max_subtasks int 1–20 8
replan_on_failure bool true
max_replans int 0–5 2
executor_recursion_limit int 1–20 6

planner_model routes the planning turn and executor_model the per-subtask agent; empty keeps spec.model for that turn, and everything else on spec.model — price overrides, fallbacks, thinking level — is carried across either way. Naming a planner route chooses which route plans, not which model configuration applies. A model named on the request outranks both.

replan_on_failure replaces the unfinished remainder of the plan when a subtask ends early, keeping the completed steps and their notes; max_replans bounds it, and 0 turns it off as surely as the boolean does. “Ends early” means the subtask stopped on refusal or max_tokens — a governance denial or a truncated answer. A short answer is not a failure, so it does not replan. max_subtasks bounds the whole run, not each plan: a replan is asked only for the budget the ceiling has left.

Field Type Default
enabled bool false
top_k int ≥ 1 3
embedding_model string "bge-base-en-v1.5"

Adds a remember_procedure tool and recalls stored how-tos as procedural memory rows.

Scope-gated tool access.

spec:
policies:
- id: notion-writes
required_scopes: [notion:write]
tools: ["notion__create_page"]
Field Type Default
id string required
description string ""
required_scopes string[] required, non-empty
tools string[] (glob, e.g. github__*) required, non-empty

Both lists are required. A rule with tools and no required_scopes permits every caller while appearing to govern them, and one with no tools gates nothing while still counting toward the soc2 profile — so both are rejected at parse rather than accepted as no-ops.

Globs match in any position and are case-sensitive. A pattern matching no bound tool is logged and counted as felix_rule_targets_nothing at compile time rather than refused, since an MCP server whose discovery failed binds no tools.

A denial emits a policy_deny audit event with status: denied.

spec:
limits:
max_tool_calls: 200
max_wall_clock_seconds: 3600
Field Type Unset means Absolute ceiling
max_tool_calls int | null 500 500
max_wall_clock_seconds float | null 3600 3600
max_peer_hops int | null 5 5
max_input_tokens int | null 1,000,000 20,000,000
max_output_tokens int | null 100,000 100,000
max_cost_usd float | null 1,000.00 1,000.00

An unset limit is not unlimited. Every run is bounded on all six: a field left out, or set to null, is filled from the harness’s defaults (DEFAULT_LIMITS), which are the ceiling for every field but max_input_tokens. Its default stays at 1,000,000 so that a manifest that declares nothing is not given a 20M-token budget, while one that needs more can still declare it. A declared value above the ceiling is refused when the manifest is parsed rather than clamped to it.

max_cost_usd is a per-run spend ceiling, accumulated from the model catalog’s prices as tokens are metered. It fails closed: a model the catalog cannot price is a pricing gap that mis-enforces the cap, so supply spec.model.price for any model you route to that the built-in table does not cover. Only a declared cap is refused on an unpriced model; the default one is still applied, and on such a model its spend reads zero, so it never trips.

spec.recursion_limit (int | null, ceiling 50) and spec.max_turns (ceiling 100) are capped the same way. A breach denies the call like any other governance layer: the tool result becomes a deny string, felix_tool_calls{status="denied"} increments, and the run records a policy_deny audit row. There is no distinct limit_exceeded event type.

spec:
guardrails:
providers: [pii]
block_on_match: true
targets: [input, output, final_response]
judges:
- name: helpfulness
criteria: "Answers the question without inventing facts."
threshold: 0.7
Field Type Default
providers list of pii []
block_on_match bool false
targets list of input | output | final_response ["input","output"]
judges JudgeRule[] []

JudgeRule: name (required), criteria (required), threshold (float 0–1, default 0.7), model (string, ""), target_tools (string[], []), final_response (bool, false), decider (bool, false).

A judge scores with, in order: spec.decider when decider: true — the probability the text meets criteria, read as written, so a negative criterion needs no assert_absent: prefix — then the model in model, then the heuristic. Each failure falls through to the next. Text longer than 8,000 characters skips the decider, which would otherwise judge only a prefix. A decider judge is a quality control, not a security boundary: the text it judges can argue for its own verdict.

providers is a closed set, like targets beside it: a typo (PII, pii-redaction) is a compile error rather than a manifest that applies no wrapper while reporting guardrails enabled.

The pii provider needs the pii extra (Presidio) plus a spaCy English model. Without both — which is the case on the lean image — it degrades to three regexes (email, US SSN, card-like digit runs), announced at WARNING with a felix_control_degraded counter, not silently. Guardrail blocks and below-threshold judge scores deny through the same path as every other layer — a policy_deny audit row and felix_tool_calls{status="denied"} — not under event types of their own.

Screens tool output for prompt injection before the model sees it.

Field Type Default Notes
enabled bool false
model string "" Empty = marker-based only; set an id to add an LLM injection score.
tools string[] [] Screened in addition to every untrusted tool — anything not executing in-process: MCP, peers, browser, sandbox, container, queue, fetch, any plugin transport — which is always screened.
on_flag quarantine | block quarantine Applies to output that is flagged and to output that could not be screened.
decider bool false Also ask spec.decider: injection, jailbreak and exfiltration in one call.
model_tools string[] (globs) [] Which screened tools get the paid scoring — model and decider. Empty = every screened tool. Needs model or decider: true.
image_model string "" A vision model that reads the text in each user image, and in each image a screened tool returns; the transcript is screened like typed text. on_flag applies per user image; a tool’s image is always quarantined, never refused. Empty = user images are not screened, and an untrusted tool’s images are quarantined. Needs enabled: true.

decider is additive: the markers run first, model runs beside it, either one flagging flags, and either one unable to run leaves the text unscreened rather than cleared. A decider is not adversarially robust, so it is an extra net, never a replacement for the model screener. The model screener and the decider read tool output and user turns window by window across the whole text; output longer than eight windows (32,000 characters) is reported unavailable, so on_flag decides.

model_tools is a cost lever, not an exemption. The model screener and the decider cost a call per window, so a manifest binding twenty MCP tools pays twenty a turn; model_tools names the ones worth paying for. The marker scan still runs on every screened tool, so an untrusted tool left out of model_tools is screened by markers alone — never unscreened. A pattern matching no bound tool is counted as felix_rule_targets_nothing.

content_screening:
enabled: true
decider: true
model_tools: ["github__get_*", "browser__*"]

Inbound user turns are screened on /chat, /v1 and /a2a as well.

A turn’s screeners read its text. Text drawn inside an image — a screenshot that says “ignore previous instructions” — is invisible to them unless image_model is set:

content_screening:
enabled: true
image_model: claude-sonnet # any vision-capable model id
on_flag: quarantine

Each user image is sent to that model to be transcribed, and the transcript goes through the same marker scan, model score and decider as the turn’s own text. Under quarantine a flagged image is removed and the turn carries [quarantined] image flagged as potentially hostile in its place; the text and any clean images still reach the agent. Under block the turn is a 422.

  • An uploaded image (POST /files, then a file content part) is read under your tenant before it is transcribed, so the screener sees exactly the bytes the model will.
  • A remote https:// image is not screened: the model provider downloads it separately, so there is no way to know the screener and the model got the same picture. It is treated as a screener outage — removed under quarantine, 503 under block. Send image bytes or upload them when screening is on.
  • Each distinct image costs one vision call, with at most 8 per request across all its messages; past that, block answers 422 too_many_images and quarantine removes the rest. Transcripts are cached per tenant by content, so a client that resends its conversation every request pays once per image.
  • A transcript counts only if the transcriber finished normally. A refusal, a filtered or truncated reply, or an empty one is treated as unscreened; NO_TEXT is the only answer that means “no text”.
  • History is screened as well as the incoming turn. An image already in the thread — sent through a different manifest in the same thread, or before image_model was set — is screened whenever the history is rebuilt for the model, including after compaction. A flagged replayed image is always quarantined, even under block: refusing would refuse every later turn of the thread, since its history can’t be rewritten. A router’s image screen also applies to the sub-agents it hands the thread to, on top of any screen of their own.
  • It screens for injection only: the input PII guardrail does not read image text.
  • The transcriber reads the image it is screening, and a crafted image can try to talk it out of reporting its text. Treat this as one layer, like every model-based screener.

A screener that cannot run has not cleared anything. If the model is unreachable — missing key, expired credential, 429, provider outage — or returns a score that cannot be parsed, the result is unavailable, not clean: on_flag: block denies (inbound: 503), quarantine replaces the content, and a felix_control_unavailable counter is emitted. This matters most on the tool-output path, which is what screens MCP, A2A, browser, and sandbox content.

Pattern rules over shell-style command arguments.

spec:
command_screening:
enabled: true
include_defaults: true
rules:
- pattern: "rm -rf /"
decision: deny
reason: "Destructive"
- pattern: "git push"
decision: require_approval
Field Type Default Notes
enabled bool false
include_defaults bool true Ship-with floor rules.
rules CommandRule[] []
target_tools string[] []
approval_ttl_seconds int, 0 < x ≤ 86400 300 How long a require_approval rule waits before failing closed.

CommandRule: pattern (1–256 chars, required), decision (allow | deny | require_approval, required), reason (string | null).

require_approval creates a real approval request and blocks on it, bounded by approval_ttl_seconds so a run cannot wait forever on an approver who never comes.

Thresholds for the worker’s anomaly scan. Read from the manifest — findings carry the values that produced them.

Field Type Default Notes
enabled bool true false disables the scan for this manifest.
min_volume int ≥ 1 10 Below this call volume the window is not scored.
baseline_factor float ≥ 1 3.0 Multiple of the baseline that counts as an anomaly.
min_rate float 0–1 0.2 Still unimplemented — deliberately, since its intended semantics are not recoverable from the code.
spec:
permissions:
default_mode: default # the mode a conversation starts in
allowed_modes: [default, plan, accept_edits] # accept_edits and bypass are opt-in
read_only_tools: [search__*] # more tools plan mode may run
edit_tools: [notes__save] # more tools accept_edits waives approvals for

Each conversation is in one permission mode, set with POST /chat/mode and shown on the session snapshot as permissionMode. It applies from the next run.

Mode What the agent may do
default Everything the rest of the manifest allows, with approvals as written.
plan Only read-only tools — the workspace’s reads, search, recall, todo_write, ask_user, a foreground task — and read_only_tools. Anything else is refused before an approval is asked. The agent presents its plan with exit_plan_mode; a person approves it through approvals (rule plan-approval, found with GET /approvals), and the conversation returns to default_mode.
accept_edits Approvals for write_file, edit_file, delete_file, rename_file and edit_tools are skipped; every other approval is asked as written.
bypass Every approval is skipped except a plan’s. It cannot be the default_mode; setting it needs the approvals:bypass scope, and each run checks that scope again for whoever drives the conversation then.

allowed_modes defaults to [default, plan]: accept_edits and bypass skip approvals you wrote, so they apply only where you list them. Plan mode binds every agent, whatever its allowed_modes — it only narrows — and allowed_modes decides whether the agent offers exit_plan_mode; a conversation whose agent does not offer it leaves plan mode through POST /chat/mode. A plan approval is single-use, for the person who asked, bound to the conversation, and expires after 30 minutes. Every skipped approval is audited (approval_waived). An MCP tool’s own read-only annotation does not count for plan mode (the server wrote it); list the tool in read_only_tools.

spec:
hooks:
- id: no-friday-deploys
event: pre_tool_use
tools: [deploy__*] # tool events only; empty means every tool
endpoint: policy # an id from FELIX_WEBHOOK_ENDPOINTS, never a URL
timeout_ms: 3000 # at most 10000
on_error: block # allow (default) or block

On each event Felix sends a signed POST to the endpoint — the operator registers it for your tenant and opens it to hooks ("hooks": true) — and acts on the JSON it answers:

{"decision": "block", "reason": "No deploys on Fridays", "additional_context": "..."}
event Fires decision: block
session_start a conversation’s first run refuses the run (422; an error frame of type blocked_by_hook on a stream)
user_prompt_submit each run, before the model sees the prompt refuses the run (as above)
pre_tool_use before a call to one of the agent’s tools — ahead of policies and approvals refuses the call
post_tool_use after a tool call, with its result (or its error) —
stop when the agent would finish sends it back with reason (up to 3 times a run, never on its last step)
subagent_stop when a task child finishes —

The request body is {type: "hook.<event>", hook, tenant_id, manifest_id, thread_id, data}, with data carrying the prompt, the tool’s name/arguments/result, or the answer (each cut to 32,000 characters). An empty 2xx is allow. additional_context — and a stop reason — reach the model screened, fenced as reference material from that hook, for that call only; one event’s hooks share 8,000 characters of it and 15 seconds. A stop hook that errors lets the agent finish. An endpoint that is down, slow, or answers anything else is an error, and on_error decides — allow treats the hook as advisory, block makes it a control that fails closed. Every call is audited (hook_call).

A hook on your own host. There is no way to point a hook at a command or script, on purpose: Felix can only run programs inside the agent’s workspace, where the agent can write — so it could rewrite the very hook that decides its calls. Run the hook as a small HTTP service the agent cannot reach (a sidecar beside the API) and have the operator register it with "private": true and "hooks": true; it receives the same signed request and answers the same JSON. Hooks run on react and deep; any other pattern refuses a manifest that declares them. Hook ids must be unique; at most 16.

Human-in-the-loop gates on named tools.

spec:
approvals:
- id: workspace-write
description: Confirm writes to the workspace
tools: [write_file]
ttl_seconds: 600
one_shot: true
bind_principal: true
Field Type Default Notes
id string required
description string "" Shown to the approver.
tools string[] []
ttl_seconds int > 0 | null null How long the run waits for a decision.
one_shot bool false The grant is consumed once — by the call that waited for the decision, or by the first identical call that finds it. Enforced with a conditional update, so two concurrent identical calls cannot both spend it: a waiting call that loses is refused with [approval already used], and one that finds the grant already spent asks for a new approval.
bind_principal bool false The grant is bound to the principal that asked; it will not authorize another user’s identical call — including while it waits: an identical call from another principal is refused rather than joining the pending request.
allow_unattended bool false Permit the call with no human present.
when_args string[] [] Gate only the calls carrying all of these arguments, non-empty. Empty gates every call.

when_args exists because a tool can be harmless in one shape and a privileged operation in another. remember is ordinary capture until it carries a topic_key, at which point its value becomes the one the model is shown as current for that topic. It no longer retires what else holds the key — only an operator does that — but a human seeing a key-changing write is still worth an approval. Gating the whole tool would put one in front of every memory write:

spec:
approvals:
- id: memory-retopic
description: Confirm a new current value for a remembered topic
tools: [remember]
when_args: [topic_key]

A call that does not carry every named argument reaches the tool untouched — no approval, no approval_required event, no added latency.

A gated call creates a pending row keyed by a SHA-256 signature of its arguments, emits approval_required, and blocks on the decision. Operators answer with POST /approvals/{id}/decide (approved / denied, optional note, optional edited_args which replace the call arguments). It fails closed: no request context, no store, or a store error all deny.

A tool can attach a preview the harness computes from the call — publish_commits attaches the diff it is about to publish. It appears on the pending row and the approval_required frame as args.preview, is never part of the signature, and is stripped from edited_args. A preview that fails refuses the call ([approval preview failed]) and writes no row, so nothing is approved unseen.

Opt-in framework mapping. These are compile-time requirements, not a certification.

spec:
governance:
frameworks: [soc2]
forbid_plaintext_secrets: true
pin_compile: true
retention_days: 365
Field Type Default Notes
frameworks list of soc2 | eu_ai_act [] Empty is a no-op, so local manifests stay valid.
risk_tier limited | high limited EU AI Act deployer hint.
transparency_notice bool false Prepends an “you are talking to an AI agent” notice.
forbid_plaintext_secrets bool false Also forced on when FELIX_ENVIRONMENT=production.
pin_compile bool false Freezes the manifest version for a thread — and every sub-agent it compiles, recursively; drift → 409.
retention_days int 1–3650 | null null Shortens this manifest’s audit rows below FELIX_AUDIT_RETENTION_DAYS, never lengthens them. Does not reach its spilled artifacts, which follow FELIX_ARTIFACT_RETENTION_DAYS.

Setting any framework forces forbid_plaintext_secrets and pin_compile to true, and then:

soc2 requires observability.trace: true, anomaly.enabled: true, at least one of policies / approvals / limits, at least one of auth.inbound.schemes / required_scopes, and auth.inbound.allow_anonymous: false outside development.

eu_ai_act requires governance.transparency_notice: true and at least one of content_screening.enabled or guardrails targeting input. With risk_tier: high it additionally requires a non-empty approvals list in which no rule sets allow_unattended: true.

Verify in CI with felix validate-manifest <file> -e production. See deploy/GOVERNANCE.md.

Anywhere a credential is accepted (mcp_servers[].auth, peers[].auth, containers[].auth) you may write a literal, secret:NAME, or {"secret": "NAME"}. Names resolve at compile through FELIX_SECRETS_BACKEND (env | file | aws | gcp) and are masked out of tool output. An unresolvable name fails the request with 503. With forbid_plaintext_secrets on, a literal is a compile error.

A minimal agent:

apiVersion: felix/v1
kind: Agent
metadata:
name: quick
description: Fast general-purpose assistant.
spec:
pattern: react
model:
id: claude-sonnet
temperature: 0
system_prompt:
inline: You are a concise, accurate assistant.
tools: [calculator]
max_turns: 8

A governed workspace agent with client tools, approvals and durable execution — this is the bundled cowork manifest the web clients drive:

apiVersion: felix/v1
kind: Agent
metadata:
name: cowork
version: 1.0.0
description: Felix workspace agent — goals, files, client tools, and approvals.
tags: [react, workspace, durable]
spec:
pattern: react
model:
temperature: 0
system_prompt:
inline: |
You are Felix, a workspace agent.
Prefer small, reversible changes. Summarize what you did when finished.
tools:
- calculator
- list_dir
- read_file
- write_file
- edit_file
- search_files
client_tools:
- name: local_shell
description: Run a shell command in the user's local environment.
args_schema:
type: object
properties:
command: { type: string }
cwd: { type: string }
required: [command]
additionalProperties: false
timeout_seconds: 300
skills:
- name: calculator-help
approvals:
- id: workspace-write
description: Confirm writes to the workspace
tools: [write_file, edit_file]
ttl_seconds: 600
- id: client-shell
description: Confirm local shell commands
tools: [local_shell]
ttl_seconds: 600
limits:
max_tool_calls: 200
max_wall_clock_seconds: 3600
max_turns: 40
execution:
mode: durable
session:
strategy: compacting
reserve_tokens: 16384
keep_recent_tokens: 20000
context_window_tokens: 128000
memory:
checkpointer: postgres
store: pgvector
auth:
inbound:
allow_anonymous: true
observability:
trace: true

Do not build on these — they validate, but no code path consumes them: spec.anomaly.min_rate.

memory.checkpointer used to be on this list. It now selects the session store for real, and the three names that could never work here — agentcore, do (Durable Objects, which this stack does not run) and sqlite — are a validation error rather than a silent alias for postgres.