Skip to content

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.

Core never imports your package by name. It discovers it through an entry point:

pyproject.toml
[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.

my_plugin/__init__.py
def register(registry) -> None:
registry.register_plugin(MyPlugin())

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)

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_model and after_model chain: each hook sees what the one before it returned.
  • before_turn hooks all see the same history, and every message they return is appended.
  • before_tool stops at the first hook that blocks. Any other answer passes the call on to the next hook, and an answer with no block key 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_tool hooks all see the tool’s own result, and their answers are merged, a later hook’s key winning.
  • before_compact stops 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.

my_plugin/__init__.py
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.

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:

pyproject.toml
[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.

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.

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 {}

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.

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 the before_tool / after_tool hooks, which run at the tool-runner boundary instead.
  • spec.guardrails.providers. Closed to the built-in set.