Concepts
Felix is a managed agents harness you run yourself. You describe an agent declaratively; Felix compiles that description into a governed, observable, resumable runtime and serves it over several protocols at once.
The through-line is: the manifest is the program, and everything else is machinery that enforces it.
Manifest
A YAML or JSON document with apiVersion: felix/v1 and kind: Agent. The schema is Pydantic v2 with
extra="forbid", so an unknown key is a hard error rather than a silently ignored typo.
A manifest declares everything an agent needs: pattern, model, system prompt, tools, skills, MCP servers, A2A peers, client tools, memory backend, session strategy, auth requirements, policies, limits, guardrails, screening, approvals, and framework governance.
apiVersion: felix/v1kind: Agentmetadata: name: quickspec: pattern: react model: id: claude-sonnet tools: [calculator]Every field is catalogued in the Manifest reference.
Where manifests come from
The request-path resolver walks four layers and returns the first hit:
- Tenant Postgres — the tenant’s active pointer (
source: tenant_postgres) - Tenant object store —
manifests/{tenant_id}/{name}.json(source: tenant_object) - Global object store —
manifests/{name}.json, affecting every tenant (source: global_object) - Bundled — the YAML shipped in the image (
source: bundled)
So a tenant manifest named quick shadows the bundled quick for that tenant alone. Active pointers
are cached for 30 seconds. Eight manifests ship in the box: quick, deep, router, oss-only,
hybrid-router, support, cowork and governed.
Writes go through the Management API, which is append-only and version-pinned: publishing inserts a new version row and flips a pointer, and rolling back flips the pointer again rather than rewriting content.
Canary rollouts
An active pointer can carry a canary_version and a canary_weight. Selection is deterministic,
not random — the harness hashes tenant, thread, manifest name and both version numbers, then takes
that modulo 100. A thread therefore stays on one side for the whole rollout instead of flapping
between versions mid-conversation.
Compile pinning
With spec.governance.pin_compile, the manifest version a thread started on is frozen for that
thread. If the active pointer moves underneath a live conversation, the next request fails with
409 rather than silently switching behaviour mid-run.
Tenant
Every row Felix writes is scoped to a tenant id, taken from the authenticated principal — never
from a request field, so a caller cannot address another tenant’s data. Under
FELIX_AUTH_MODE=none everything is the anonymous principal in tenant default.
Postgres row-level security is available as opt-in defence in depth. The policies themselves are
not optional to install — 0006_tenant_rls is part of the migration chain, so any database at that
revision or later has them. FELIX_DATABASE_RLS is the runtime half: set it true and the tenant
id is applied per transaction and the policy enforces it; leave it false and Felix declares a
bypass, so the policies are inert and query-layer scoping remains the isolation.
One caveat is worth checking rather than assuming, because it fails silently in the direction that
matters: the policies are FORCEd, and a superuser or BYPASSRLS connection skips them
entirely — so RLS can be switched on and enforcing nothing while everything appears to work.
felix doctor reports whether it is actually in effect.
Thread
A thread is one conversation. Its id is always {tenant_id}:{suffix}, where clients supply only the
suffix — a suffix containing : or # is rejected with 400 invalid_thread_id.
Session
The session is the append-only event log behind a thread. It lives outside the model’s context window: the log is the durable record, and a strategy decides what the model actually sees each turn.
Event kinds: message, tool_call, tool_result, thinking, audit, compaction,
model_change, thinking_level_change, branch_summary, custom, label, session_info.
Strategies (spec.session.strategy):
| Strategy | Behaviour |
|---|---|
full_replay |
Send the whole transcript. The default. |
windowed:N |
Send the last N events. |
summarizing:N |
Summarize everything older than the last N. |
semantic:N |
Retrieve the N most relevant events (needs the embeddings extra). |
compacting |
Compact on a token threshold, keeping keep_recent_tokens intact. |
Because the log is authoritative and server-side, a client is a view, not the source of truth. It
hydrates from GET /chat/sessions/{id}, whose snapshot carries the transcript, phase, thinking
level, leaf, lease state and any queued steering.
A session moves through phases idle, turn, compaction, branch_summary, retry and aborted.
Branching
A thread is a tree, not a line. Fork copies a session into a new thread up to a chosen event.
Rewind moves the active leaf back to an earlier event in the same thread; unless you opt out, the
abandoned branch is summarized into a branch_summary event so the model does not lose what happened
there.
Leases
Two browser tabs on one session would race. Each client mints a holder id and takes a lease —
exclusive (reported as locked) or shared (reported as attached). Contention returns 409.
Leases are Redis-backed, so they hold across replicas.
Pattern
The pattern is the control loop. Felix ships seven:
| Pattern | Kind | Behaviour |
|---|---|---|
react |
single | Think → call tools → observe → repeat. The workhorse. |
deep |
single | Plans first, records steps as a plan document, then executes. |
reflect |
single | Generates, critiques against criteria, and retries below threshold. |
plan_execute |
single | Separate planner and executor models. |
router |
multi | Classifies the request and delegates to one sub-agent. |
parallel |
multi | Fans out to sub-agents and aggregates. |
groupchat |
multi | Sub-agents converse to a joint answer. |
The registry is open — register_pattern(name, builder) adds your own, and nothing in core
enumerates the built-in list. Multi-agent patterns require sub_agents and reject the direct
outbound blocks (peers, containers, queues, sandboxes, browser_tools); a sub-agent’s own
manifest declares those instead. See Patterns.
Skill
A SKILL.md file with YAML frontmatter, living under skills/<name>/. The frontmatter can fold
extra tools, MCP servers and peers into any manifest that lists the skill; the body is appended to
the system prompt under an ## Active Skills header.
Activation is per-tenant and restriction-only: a tenant overlay can disable skills the manifest
declares, but can never enable ones it did not. Agents can manage their own overlay through
list_skills, activate_skill and deactivate_skill.
Tool
Every tool, wherever it runs, is compiled to the same interface and tagged with a transport that shows up in audit rows:
| Transport | Runs where | Declared by |
|---|---|---|
local |
In the API process | spec.tools |
mcp |
A remote MCP server | spec.mcp_servers → {server}__{tool} |
a2a |
Another Felix peer | spec.peers → peer__{name} |
container |
An HTTPS container gateway | spec.containers |
sandbox |
A Docker sandbox (sandbox extra) |
spec.sandboxes |
browser |
Playwright (browser extra) |
spec.browser_tools |
queue |
A separate consumer, via a Redis list | spec.queues |
client |
The caller’s own machine | spec.client_tools |
Tool failures come back as strings the model can read and react to, prefixed
[error/invalid_arguments], [error/timeout], [error/blocked] or [error/internal]. A ref with
fatal: true aborts the batch instead.
Client-executed tools
spec.client_tools inverts the usual direction: the harness emits a tool_request frame and
blocks, and the browser or CLI runs the tool and answers with POST /chat/tool_result. This is a
real round trip inside the model loop — a client that never answers stalls the run until the tool’s
timeout. It is how the web clients run local shell commands and open local files against a workspace
the server cannot see.
Queue tools
A queue tool pushes onto a Redis list and returns immediately; a separate consumer does the work and
writes the result back with POST /internal/sessions/{session_id}/events, authenticated with
X-Felix-Consumer-Secret. Use it for work that outlives a request.
Governance
Every tool is cloned through a fixed wrapper stack before the model can call it. The order is load-bearing and enforced by tests:
secret masking → policies → command screening → content screening → limits → guardrails → judges → approvals → artifact spill
| Layer | Manifest block | Question it answers |
|---|---|---|
| Secret masking | — | Is a resolved credential about to leak into output? |
| Policies | spec.policies |
Does the caller hold the scopes this tool requires? |
| Command screening | spec.command_screening |
Is this command allowed, denied, or does it need a human? |
| Content screening | spec.content_screening |
Is this tool output trying to hijack the model? |
| Limits | spec.limits |
Has this run exceeded its budget? |
| Guardrails | spec.guardrails |
Does the text match a blocked pattern (e.g. PII)? |
| Judges | spec.guardrails.judges |
Does a model score this as acceptable? |
| Approvals | spec.approvals |
Has a human said yes? |
| Artifacts | spec.artifacts |
Is this output too large to put in context? |
A denial is stamped with a module-private marker, so a model cannot forge one by emitting a convincing-looking string. Governance fails closed — a missing store or an internal error denies rather than allows. See Governance.
spec.governance.frameworks additionally maps a manifest onto soc2 or eu_ai_act requirements and
refuses to compile without the corresponding controls. That is a compile-time check, not a
certification.
Memory
Two independent things share the word.
Session memory is the transcript, above — the checkpointer (spec.memory.checkpointer, Postgres)
plus a strategy.
Durable memory is facts that outlive a thread. With spec.memory.capture.enabled, a small model
extracts facts from each turn into memory_vectors (pgvector, HNSW) under kinds fact,
preference, episode and procedural. spec.procedural_memory recalls stored how-tos and adds a
remember_procedure tool. Semantic recall and tools_retrieval need the embeddings extra.
Durable execution
By default a run lives and dies with the request. With spec.execution.mode: durable, POST /chat
enqueues a fiber and returns 202 with a resume_token; the fiber checkpoints into Postgres and
the worker’s fiber_scheduler cron resumes it, so a process restart does not lose the run. Poll
GET /chat/runs/{resume_token} for the result.
Setting FELIX_DURABILITY=temporal additionally drives a Temporal workflow on the felix-fibers
task queue, falling back to the fiber scheduler if that fails.
POST /chat/stream ignores execution.mode: durable and always runs inline.
Steering
A run is not a black box once it starts. POST /chat/steer with kind: "steer" interrupts the
remaining tool batch mid-run; kind: "follow_up" queues a message delivered once the run goes idle.
POST /chat/abort stops it, and POST /chat/continue picks it back up with no new user message. The
queues are Redis lists, so any replica can accept the steer.
Interrupts
Three things pause a run until something answers out of band: an approval_required frame waiting on
a human decision, a tool_request frame waiting on the client, and a ui_request frame asking the
user to choose, confirm or type something. Each has a timeout and fails closed. See
sticky interrupts.
Surfaces
The same compiled agent is reachable four ways, which is the point of compiling from a manifest rather than wiring a framework by hand:
| Surface | Entry point |
|---|---|
| Native REST + SSE | POST /chat, POST /chat/stream |
| OpenAI-compatible | POST /v1/chat/completions (model = manifest name) |
| A2A JSON-RPC | POST /a2a |
| MCP | POST /mcp |
Auth
Three modes, set by FELIX_AUTH_MODE:
none— everything is anonymous in tenantdefault, and management scope checks are skipped. Legal only on a loopback bind; the harness refuses to start otherwise, in every environment.api_key—Authorization: Bearer,Authorization: ApiKeyorX-Api-Key, compared in constant time againstFELIX_AUTH_API_KEYS, which supplies the subject, tenant and scopes.jwt— verified againstFELIX_JWT_VERIFIERSandFELIX_JWKS_PUBLIC. Mint tokens locally withfelix mint-jwt.
A manifest adds its own inbound requirements through spec.auth.inbound — allow_anonymous and
required_scopes are enforced per request. Management routes layer scopes on top; see
Scopes. A second credential,
X-Felix-Consumer-Secret, gates POST /internal/*.
Details in Auth.
Observability
Prometheus metrics at GET /metrics, counters prefixed felix_*. Audit rows are buffered by the
process that emits them and flushed
to Postgres on a timer plus a drain on shutdown — the agent loop runs in the API process, so the API
flushes too, not only the worker. Optional OTLP tracing with FELIX_OTEL_ENABLED, and an optional
append-only analytics spill (FELIX_WAREHOUSE: DuckDB, ClickHouse or Doris).
See Observability.
Request lifecycle
Putting it together, one POST /chat:
-
Middleware — request id, then body limit (1 MiB), then per-tenant rate limit, then authentication. All four are pure ASGI, so a streamed response is not copied between layers.
-
Resolve — walk the four manifest layers, pick the canary variant, check the compile pin.
-
Authorize — apply
spec.auth.inbound; screen the inbound user turn. -
Compile —
build_agentresolves the prompt and sub-agents, binds outbound tools, wires skills and memory, and wraps every tool in the governance stack. -
Run — the pattern loop calls the model and dispatches tools, emitting audit rows and stream frames, and pausing on any interrupt.
-
Persist — append events to the session log; buffer audit and usage for flush.
For the mechanism at each step, start at Architecture.