Quick Start
This chapter walks through a complete extension named hello. It contributes one middleware that times every tool call and logs a warning when a call is slower than a configured threshold. By the end you will have packaged it, unit-tested it without DeerFlow, installed it into a checkout, and seen it run.
Prerequisites
- A DeerFlow checkout that runs with
make dev. See Quick Start. - Python 3.12 or newer and uv 0.8.0 or newer. The extension manager refuses older uv.
- Shell access to the machine running the Gateway. Installing an extension is an operator action, not something the web UI does.
Create the package
An extension is a normal Python package. Create it outside the DeerFlow checkout, for example in ~/src/deerflow-extension-hello:
deerflow-extension-hello/
├── pyproject.toml
├── deerflow_extension_hello/
│ └── __init__.py
└── tests/
└── test_hello.pypyproject.toml declares the contract range, every framework the code imports, and exactly one entry point in the deerflow.extensions group:
[project]
name = "deerflow-extension-hello"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"deerflow-extension-api>=0.2,<0.3",
"langchain>=1.3,<2",
]
[project.entry-points."deerflow.extensions"]
hello = "deerflow_extension_hello:install"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["deerflow_extension_hello"]The entry-point name, hello, becomes the operator-facing name used by enable, disable, and remove.
deerflow-extension-api deliberately has no dependencies. If your code imports
LangChain, LangGraph, or FastAPI, declare them yourself, as langchain is
declared here. Never import deerflow.* or app.*: those are host internals
with no compatibility promise.
Write install()
"""Log how long each tool call takes, as the model sees it."""
from __future__ import annotations
import logging
import time
from collections.abc import Mapping, Sequence
from typing import Any
from deerflow_extension_api import (
AgentBuildContext,
AgentScope,
ExtensionData,
ExtensionRegistry,
MiddlewarePlacement,
Placement,
extension,
)
from langchain.agents.middleware import AgentMiddleware
logger = logging.getLogger(__name__)
class ToolTimer(AgentMiddleware):
def __init__(self, slow_ms: float) -> None:
super().__init__()
self.slow_ms = slow_ms
def _report(self, request: Any, started: float) -> None:
elapsed_ms = (time.perf_counter() - started) * 1000
level = logging.WARNING if elapsed_ms >= self.slow_ms else logging.INFO
logger.log(level, "tool %s took %.1f ms", request.tool_call.get("name"), elapsed_ms)
def wrap_tool_call(self, request, handler):
started = time.perf_counter()
try:
return handler(request)
finally:
self._report(request, started)
async def awrap_tool_call(self, request, handler):
started = time.perf_counter()
try:
return await handler(request)
finally:
self._report(request, started)
class ToolTimerContributor:
def __init__(self, slow_ms: float) -> None:
self.slow_ms = slow_ms
def contribute_middlewares(
self,
app_store: ExtensionData,
ctx: AgentBuildContext,
) -> Sequence[MiddlewarePlacement]:
return (MiddlewarePlacement(ToolTimer(self.slow_ms), Placement.TOOL_VISIBLE, AgentScope.BOTH),)
@extension(api="0.2.0", name="hello")
def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None:
registry.middlewares(ToolTimerContributor(float(config.get("slow_ms", 1000))))Three things to notice:
install()only registers. It runs once at Gateway startup, before any agent exists. The host callscontribute_middlewares()later, each time it assembles an agent.Placement.TOOL_VISIBLEasks for the outer end of the tool chain, so the timing includes output truncation and error wrapping: what the model finally waits for.AgentScope.BOTHinstalls the middleware into the Lead Agent and into every subagent. Both are explained in Middleware Contributions.- The middleware implements both
wrap_tool_callandawrap_tool_call. If you implement only one side, the other execution path passes through without observing anything.
Test it without DeerFlow
The contract is plain Python, so a stand-in registry is enough to test registration, and the middleware can be called directly:
import asyncio
import logging
from types import SimpleNamespace
from deerflow_extension_api import AgentBuildContext, AgentScope, ExtensionData, Placement
from deerflow_extension_hello import ToolTimer, install
class RecordingRegistry:
def __init__(self):
self.contributors = []
def middlewares(self, contributor):
self.contributors.append(contributor)
def test_install_registers_one_tool_visible_middleware():
registry = RecordingRegistry()
install(registry, {"slow_ms": 50})
(contributor,) = registry.contributors
ctx = AgentBuildContext(scope=AgentScope.LEAD)
(placement,) = contributor.contribute_middlewares(ExtensionData("app"), ctx)
assert placement.placement is Placement.TOOL_VISIBLE
assert placement.middleware.slow_ms == 50
def test_slow_tool_calls_log_a_warning(caplog):
timer = ToolTimer(slow_ms=0)
request = SimpleNamespace(tool_call={"name": "web_search"})
async def handler(req):
return "result"
with caplog.at_level(logging.INFO):
assert asyncio.run(timer.awrap_tool_call(request, handler)) == "result"
assert "tool web_search took" in caplog.text
assert caplog.records[-1].levelno == logging.WARNINGThe contract package is currently sourced from the DeerFlow checkout, so install it from there in editable mode:
cd ~/src/deerflow-extension-hello
uv venv --python 3.12
uv pip install -e /path/to/deer-flow/backend/packages/extension-api -e . pytest
uv run --no-project pytest -qInstall it into DeerFlow
From the root of the DeerFlow checkout, pass the package directory as an absolute path. The Make wrapper runs the manager from backend/, so a relative path would resolve against the wrong directory:
make extension-install SOURCE="$HOME/src/deerflow-extension-hello"The manager warns that the extension will execute with Gateway privileges and asks Install this trusted source? [y/N]. After you confirm, it:
- copies a snapshot of the directory to
backend/extensions/sources/deerflow-extension-hello/. Later edits to your working copy are not picked up until you runmake extension-upgrade; - adds the snapshot to the
extensionsdependency group inbackend/pyproject.tomland updatesbackend/uv.lock; - syncs the locked environment;
- appends an enabled
plugins:record toconfig.yaml.
It then prints Installed and enabled hello (deerflow-extension-hello). Restart DeerFlow to load it. Check the record:
make extension-listNAME STATE PACKAGE ENTRY POINT
hello enabled deerflow-extension-hello deerflow_extension_hello:installConfigure and restart
The manager writes an empty private config: {}. To lower the slow-call threshold, edit the record in config.yaml. The manager preserves this block across enable, disable, and upgrade:
plugins:
- name: hello
package: deerflow-extension-hello
use: deerflow_extension_hello:install
enabled: true
required: false
config:
slow_ms: 200Extensions load only while the Gateway starts, so restart it:
make devThe Gateway log confirms the load:
Extensions loaded: 1/1 (deerflow_extension_hello:install)If the entry point cannot be imported, is incompatible, or install() raises, the count reads 0/1 and an error line starting with Extension deerflow_extension_hello:install: explains why. The Gateway still starts, because the record has required: false.
See it run
Send a message that makes the agent use a tool, such as a web search. The Gateway log shows one line per tool call, from the Lead Agent and from any subagent it delegates to. The exact prefix depends on your logging format:
WARNING deerflow_extension_hello: tool web_search took 1432.7 msDisable or remove it
make extension-disable NAME=hello # keep the package and config, stop loading it
make extension-enable NAME=hello
make extension-remove NAME=hello # uninstall, delete the record and the snapshotEvery command takes effect after the next restart. If you deploy with Docker, rebuild the Gateway image after changing the installed set; a built production container never installs extensions at startup.
Next steps
- Middleware Contributions: the five placements, scope and ordering, and what an extension middleware may and may not change.
- The bundled example combines middleware with task-lifecycle state, a system-model observer, a service, and an HTTP route.