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.
Top-level shape
Section titled “Top-level shape”apiVersion: felix/v1 # default; only this exact value is acceptedkind: Agent # default; only this exact value is acceptedmetadata: { ... } # requiredspec: { ... } # defaults to a minimal react agentValidate one without starting the stack:
uv run felix validate-manifest manifests/quick.yamluv run felix validate-manifest manifests/governed.yaml -e production # applies production rulesmetadata
Section titled “metadata”| 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
Section titled “spec.pattern”spec: pattern: react # defaultBuilt-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
Section titled “spec.model”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
Section titled “spec.system_prompt”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.
spec.prompts
Section titled “spec.prompts”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. |
Slash commands
Section titled “Slash commands”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.
spec.output_styles
Section titled “spec.output_styles”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.
spec.network
Section titled “spec.network”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_toolsrefuse a URL on any other host, and the same check runs on every redirect hop:http_fetch_error: egress_blocked: destination not permitted.browser_toolscannot 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 containergateway_url, and anhttp_toolspath_prefix. A manifest naming one that is not is refused when it is written. - Child agents (sub-agents and
delegationchildren, 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 nomanifestargument: the agent answering on the far side is the peer’s default, not one the model picks.
Credentials the model never holds
Section titled “Credentials the model never holds”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.
spec.tools
Section titled “spec.tools”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.
spec.workspace
Section titled “spec.workspace”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 | deploymentscope |
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
threadworkspace. A call that carries no thread — an MCP or A2A call that names none — is refused underscope: threadrather than given a shared directory. deploymentis the operator’s. The root holds every other scope, so it is honoured only for the tenants inFELIX_WORKSPACE_DEPLOYMENT_TENANTS(comma-separated, defaultdefault). 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 thedeploymentscope 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-rundocker compose exec api felix workspace migrate --keep AGENTS.mdspec.tool_guidance
Section titled “spec.tool_guidance”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
Section titled “spec.skills”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. |
spec.skill_authoring
Section titled “spec.skill_authoring”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: falseIn 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.
spec.mcp_servers
Section titled “spec.mcp_servers”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.
spec.peers
Section titled “spec.peers”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_TOKENname and url are required; auth accepts a literal or secret:NAME. Hop depth is bounded by
limits.max_peer_hops.
spec.containers
Section titled “spec.containers”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. |
spec.queues
Section titled “spec.queues”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.
spec.sandboxes
Section titled “spec.sandboxes”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 |
spec.shell_tools
Section titled “spec.shell_tools”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.
Background commands
Section titled “Background commands”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_outputcalls 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
lostor 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_outputpoll 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: trueThe 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.
spec.browser_tools
Section titled “spec.browser_tools”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.
spec.image_tools
Section titled “spec.image_tools”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 alisttool 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.
spec.search_tools
Section titled “spec.search_tools”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.
spec.http_tools
Section titled “spec.http_tools”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.
spec.github_publish
Section titled “spec.github_publish”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_shais 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 ofhead_sha; the ref update isforce: 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_shaand attaches it as the row’spreview(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_URLunset — 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.
spec.client_tools
Section titled “spec.client_tools”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 |
The user’s folder: local_* file tools
Section titled “The user’s folder: local_* file tools”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.delegation
Section titled “spec.delegation”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
promptthe 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_compilecovers the children. - The child’s own door. Each call checks the child’s
spec.auth.inboundagainst the caller — anonymity, schemes,required_scopes— so a caller who could not call the child by name cannot reach it throughtask, 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.
taskpasses 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
limitsas well as its own: a lead capped atmax_cost_usd: 0.50cannot buy more by delegating to an uncapped child. Everytaskcall counts againstmax_peer_hops, shared withpeers.
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
taskcall 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_DAYSunused, 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_commitsfrom 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 and spec.recursion_limit
Section titled “spec.max_turns and spec.recursion_limit”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 50They 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
Section titled “spec.memory”spec: memory: checkpointer: postgres # default; `none` also built in store: pgvector # defaultThere 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.
memory.consolidate
Section titled “memory.consolidate”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
/memoryare never shown and never retired. Facts of a differentkind, or a differenttopic_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
Section titled “spec.session”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.
Clearing stale tool results
Section titled “Clearing stale tool results”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_turnsuser messages and is longer thanmin_charsor carries an image. The cut-off advanceskeep_turnsuser messages at a time, so betweenkeep_turnsand2 × keep_turns − 1of 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.artifactsspilled names its artifact id in the stub, soread_artifactstill 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
/v1surface) has its messages passed through as written. - Prompt caching. For
full_replayandcompacting, the cached prefix changes once everykeep_turnsuser messages rather than on every call.windowed:N,summarizing:Nandsemantic:Nchange their prefix every turn anyway. - Clearing runs before replay image screening, so a stubbed screenshot is not screened again.
POST /chat/askreads 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
Section titled “spec.auth”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.a2a and spec.observability
Section titled “spec.a2a and spec.observability”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
Section titled “spec.execution”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.
spec.tools_retrieval
Section titled “spec.tools_retrieval”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.
spec.decider
Section titled “spec.decider”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.
spec.skill_suggestion
Section titled “spec.skill_suggestion”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. |
spec.artifacts
Section titled “spec.artifacts”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,0keeps forever); after thatread_artifactreports 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.
spec.reflect
Section titled “spec.reflect”| 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.
spec.plan_execute
Section titled “spec.plan_execute”| 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.
spec.procedural_memory
Section titled “spec.procedural_memory”| 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.
spec.policies
Section titled “spec.policies”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
Section titled “spec.limits”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
Section titled “spec.guardrails”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.
spec.content_screening
Section titled “spec.content_screening”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.
Images
Section titled “Images”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: quarantineEach 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 afilecontent 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 underquarantine,503underblock. 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,
blockanswers422 too_many_imagesandquarantineremoves 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_TEXTis 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_modelwas set — is screened whenever the history is rebuilt for the model, including after compaction. A flagged replayed image is always quarantined, even underblock: 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.
spec.command_screening
Section titled “spec.command_screening”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.
spec.anomaly
Section titled “spec.anomaly”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
Section titled “spec.permissions”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 forEach 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
Section titled “spec.hooks”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 blockOn 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.
spec.approvals
Section titled “spec.approvals”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.
spec.governance
Section titled “spec.governance”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.
Secret references
Section titled “Secret references”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.
Examples
Section titled “Examples”A minimal agent:
apiVersion: felix/v1kind: Agentmetadata: 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: 8A governed workspace agent with client tools, approvals and durable execution — this is the bundled
cowork manifest the web
clients drive:
apiVersion: felix/v1kind: Agentmetadata: 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: trueFields that parse but do nothing yet
Section titled “Fields that parse but do nothing yet”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.