Plugins
Felix is built to not dictate your workflow. Features other harnesses bake in are meant to be
added from outside: core stays minimal, and everything below is open by design. Source:
felix.plugins plus the open registries in felix-run/felix.
Installing a plugin
Section titled “Installing a plugin”Core never imports your package by name. It discovers it through an entry point:
[project.entry-points."felix.plugins"]my-plugin = "my_plugin:register"Install the package alongside Felix and register(registry) is called once at startup, in both
the API and the worker.
def register(registry) -> None: registry.register_plugin(MyPlugin())What the registry accepts
Section titled “What the registry accepts”Core reads these members off your plugin object with getattr, so implement only what you need.
| Capability | Hook |
|---|---|
| Tools | plugin.register_tools(register) |
| HTTP routes | plugin.routes(app, tools=…) |
| Cron tasks | plugin.cron_tasks |
| Rate-limit keys | plugin.rate_limit_key(request) |
| Body limits | plugin.body_limit_bytes |
| Self-authenticating mounts | plugin.self_authenticating_mounts |
| Auth modes | registry.register_authenticator(mode, builder) |
| Audit / usage sinks | registry.register_audit_sink(f) / register_usage_sink(f) |
| Startup hooks | registry.register_startup_hook(hook) |
| Run finished | registry.register_run_finished(hook, origin="<plugin>"): hook(settings, RunFinished) in the worker when a durable run that plugin started ends (completed, failed, expired or dead), after its status is saved. Only runs whose start_durable_chat(origin=...) named {"plugin": "<plugin>"} reach it, so no hook sees another plugin’s runs. At most once today, so make it idempotent on run_id; bounded to 10 s. RunFinished is keyword-only: tenant, thread, run id, status, answer, error, origin. The felix-channels plugin posts Slack replies this way. |
| Agent-loop hooks | register_before_turn, register_filter_history, register_before_compact, register_before_model, register_after_model, register_before_tool, register_after_tool, register_compact_failed (see below) |
Agent-loop hooks
Section titled “Agent-loop hooks”These run for every manifest in the process. Each hook is called positionally as
hook(value, ctx) — after_tool takes (tool_call, result, is_error, ctx) — and may be sync or
async. ctx carries manifest_id and thread_id (the model hooks add model_id and
purpose), so a hook that should apply to one agent checks ctx["manifest_id"] itself; no
manifest field attaches a hook.
| Hook | When | Return to act |
|---|---|---|
filter_history |
once per request, as the history is assembled | a new message list, which becomes the history |
before_turn |
before each model call in the loop, a queued follow-up’s included | messages to append to the history |
before_model |
before every model call, after before_turn |
{"messages": [...]}: what this call sends |
after_model |
after every model reply | {"message": ChatMessage(role="assistant", ...)}: the reply the run keeps |
before_tool |
before each tool call | {"block": True, "reason": "...", "terminate"?: True} |
after_tool |
after each tool call, including a failed one, where result is the error text the model would see |
{"content"?: "...", "terminate"?: True} |
before_compact |
before compacting a session | a custom {"summary": ...}, or {"cancel": True} |
compact_failed |
when compaction fails | nothing; it is a notification |
Return None to leave things as they are. Several hooks of one kind run in registration order,
and how their answers combine depends on the kind:
filter_history,before_modelandafter_modelchain: each hook sees what the one before it returned.before_turnhooks all see the same history, and every message they return is appended.before_toolstops at the first hook that blocks. Any other answer passes the call on to the next hook, and an answer with noblockkey at all is logged as a mistake (it is usually a misspelt block, such as{"deny": True}).{"block": False}is a valid way to allow.after_toolhooks all see the tool’s own result, and their answers are merged, a later hook’s key winning.before_compactstops at the first hook that cancels or supplies a summary.
A before_tool block is recorded like a governance denial: a policy_deny audit row with
payload.control set to hook and payload.hook naming the hook that blocked
(module.qualname), and felix_tool_calls{status="denied"}. The reason the hook gave goes to the
model, not into the row. No after_tool hook runs for a blocked call, since the tool never ran.
An after_tool hook that redacts output covers failures too, whether the tool raised or
returned an error: an error message is where a secret or an internal path is most likely to
appear. On a failed call result is the text the model would see, and content replaces it.
When the original text was a failure or a refusal ([error/...], [tool error/...],
[policy ...], …) and the replacement does not start with one, the original tag is put back in
front, so the call still counts as a failure for the model and for eval.
Write hooks with the positional parameters above, not **kwargs: a hook is called as
hook(value, ctx), so a keyword-only one raises on every call. The hook types in felix.hooks
(BeforeToolHook, AfterModelHook, …) spell the parameters out, and each
registry.register_* takes its type, so a type checker refuses that hook at the call that
registers it.
The model hooks. before_model receives {"messages", "tools"} (tool names) and its
replacement goes to that one call only: the run’s history and the session log keep the
originals, so the next call sees them again. That is the difference from filter_history, which
rewrites the history, and from before_turn, which can only add to it. after_model receives
{"message", "stop_reason"}, and its replacement is what the run records and acts on, tool calls
included — drop a call from the reply and the tool never runs.
They run on every model call a run makes, not only an agent’s turns, and ctx["purpose"] says
which kind of call it is:
purpose |
The call | A replacement changes |
|---|---|---|
turn |
a step of the react loop, including each sub-agent’s |
what the agent says and does |
router |
the router pattern choosing a sub-agent |
which sub-agent gets the request |
reflect |
the reflect verifier scoring a draft |
the score, and so whether it drafts again |
plan |
the plan_execute planner, opening plan and every replan |
the subtasks the executor runs |
synthesis |
parallel and plan_execute composing their answer |
the answer |
So a hook that redacts what reaches the model should usually apply to every purpose, while one
that rewrites answers should check for turn or synthesis and leave the router’s
classification alone.
from felix_ai.types import ChatMessage
def _drop_shell_calls(response, ctx): message = response["message"] kept = [c for c in message.tool_calls or [] if c.name != "shell"] if len(kept) == len(message.tool_calls or []): return None return {"message": ChatMessage(role="assistant", content=message.content, tool_calls=kept)}
def register(registry) -> None: registry.register_after_model(_drop_shell_calls)One limit to the model hooks: after_model cannot change text that has already streamed to the
client, since deltas are sent as the model writes them. Its replacement is still what the run
records and returns as the answer.
Command-line entry points
Section titled “Command-line entry points”There is no CLI seam. felix’s subcommands are defined in felix_cli/main.py and a plugin
cannot add one. A plugin that wants a command line declares its own console script alongside its
felix.plugins entry point, and is invoked under its own name:
[project.entry-points."felix.plugins"]my-plugin = "my_plugin:register"
[project.scripts]my-plugin = "my_plugin.cli:main"It can still reach everything the harness exposes — Settings, the stores, require_mgmt_scopes
— so this costs a second binary name, not capability.
Open registries
Section titled “Open registries”These live in core and are called at import time, not on the registry object. Each is selected by ordinary configuration, so a registered backend needs no further wiring.
| Register | Selected by |
|---|---|
felix.patterns.registry.register_pattern |
spec.pattern |
felix.patterns.model_registry.register_model_provider |
FELIX_MODEL_ROUTES |
felix.storage.register_object_store |
FELIX_OBJECT_STORE |
felix.secrets.register_secrets_backend |
FELIX_SECRETS_BACKEND |
felix.warehouse.register_warehouse_backend |
FELIX_WAREHOUSE |
felix.memory.embedder.register_embedder_backend |
FELIX_MEMORY_EMBEDDER |
felix.search.register_search_backend |
FELIX_SEARCH_BACKEND |
felix.session.strategies.register_session_strategy |
spec.session.strategy |
felix.session.store.register_checkpointer |
spec.memory.checkpointer |
from felix.storage import register_object_store
def register(registry) -> None: register_object_store("azure-blob", lambda settings: AzureBlobStore(settings))Then FELIX_OBJECT_STORE=azure-blob. An unknown backend fails at startup with the list of
registered names, so a typo is caught immediately rather than at first write.
Configuring your plugin from a manifest
Section titled “Configuring your plugin from a manifest”The manifest schema is extra="forbid" everywhere except one field, so plugin config has a home
without loosening validation:
spec: pattern: my-pattern extensions: my-plugin: greeting: "hei"A registered pattern reads it from the build context:
config = (ctx.get("extensions") or {}).get("my-plugin") or {}Skills need no code
Section titled “Skills need no code”Drop a SKILL.md into the directory named by FELIX_SKILLS_DIR, or upload one per tenant to the
object store. Neither requires a package. A tenant’s own skills — written by an agent with
spec.skill_authoring or by an operator — go through the skill library
and its review step instead.
What is deliberately closed
Section titled “What is deliberately closed”Two things are not extension points, on purpose:
- The governance wrapper stack in
manifests/builder.py. Its nine wrappers run in a fixed order and each clones the tool with a new executor, so order defines precedence. A plugin-supplied wrapper would be an unordered hole through it. Use thebefore_tool/after_toolhooks, which run at the tool-runner boundary instead. spec.guardrails.providers. Closed to the built-in set.