Reference
This page lists every name in deerflow_extension_api.__all__ for contract version 0.2.4. Import them from the package root:
from deerflow_extension_api import ExtensionRegistry, MiddlewarePlacement, PlacementThe package has no dependencies and never imports DeerFlow. Types written as Any below, such as a middleware or a router, are validated by the host at runtime rather than by the contract.
Entry point and registry
ExtensionInstall
ExtensionInstall = Callable[[ExtensionRegistry, Mapping[str, Any]], None]The signature of the function named by a plugins: record’s use. The second argument is a shallow copy of the record’s config.
extension(*, api, name=None)
Decorator that stamps an install function with the contract version it was written against (__deerflow_api__) and an optional name (__deerflow_name__). Optional; see Compatibility rules.
@extension(api="0.2.0", name="hello")
def install(registry: ExtensionRegistry, config: Mapping[str, Any]) -> None: ...ExtensionRegistry
A runtime_checkable Protocol: the write-only surface passed to install(). Every method has a default implementation that registers nothing. A host whose registry predates a method inherits that default, so the call succeeds but the contribution is not registered. plugin() is the one method that reports this, by returning False; the version marker and package metadata are how you prevent it for the others.
| Method | Returns | Registers |
|---|---|---|
middlewares(contributor: MiddlewareContributor) | None | A middleware contributor |
task_lifecycle(contributor: TaskLifecycleContributor) | None | Start and stop hooks for lead runs and subagents |
system_model_observer(observer: SystemModelCallObserver) | None | An observer of DeerFlow-owned model calls |
agent_assembly_observer(observer: AgentAssemblyObserver) | None | An observer of assembled agents |
context_compaction_observer(observer: ContextCompactionObserver) | None | An observer of summarization |
service(service: ExtensionService) | None | A Gateway-lifetime service |
routers(routers: Sequence[Any]) | None | FastAPI routers, built during install() |
plugin(contribution: PluginContribution) | bool | A full-stack plugin. True when accepted, False on a host without plugin support. Experimental |
Middleware
MiddlewareContributor
class MiddlewareContributor(Protocol):
def contribute_middlewares(
self, app_store: ExtensionData, ctx: AgentBuildContext
) -> Sequence[MiddlewarePlacement]: ...Called on every agent assembly. Default returns ().
MiddlewarePlacement
Frozen dataclass.
| Field | Type | Default |
|---|---|---|
middleware | Any (must be a LangChain AgentMiddleware) | required |
placement | Placement | required |
scope | AgentScope | AgentScope.BOTH |
order | int | 0 |
Placement
StrEnum: MODEL_LOGICAL = "model_logical", MODEL_PHYSICAL = "model_physical", TOOL_VISIBLE = "tool_visible", TOOL_RAW = "tool_raw", STANDARD = "standard". The guarantee each one makes is in Middleware Contributions.
AgentScope
Flag: LEAD, SUBAGENT, BOTH = LEAD | SUBAGENT.
AgentBuildContext
Frozen dataclass passed to contribute_middlewares().
| Field | Type | Default |
|---|---|---|
scope | AgentScope | required |
agent_name | str | None | None |
model_name | str | None | None |
policy | HostPolicySnapshot | HostPolicySnapshot() |
HostPolicySnapshot
Frozen dataclass: the limits the host enforces, projected so extensions do not depend on DeerFlow’s config types. Every field has a default.
| Field | Type | Default |
|---|---|---|
token_budget_enabled | bool | False |
max_input_tokens | int | None | None |
max_output_tokens | int | None | None |
max_total_tokens | int | None | None |
budget_warn_fraction | float | None | None |
budget_hard_fraction | float | None | None |
max_subagents_per_run | int | None | None |
State
ExtensionData
Typed, thread-safe store attached to one host scope (the app, or one task). Keyed by Python type, so two extensions cannot collide.
| Member | Description |
|---|---|
ExtensionData(scope_id: str) | Constructor. The host creates stores; construct one yourself only in tests |
scope_id: str | Host identity of the scope |
get(typ: type[T]) -> T | None | The stored instance of typ, or None |
get_or_init(typ: type[T], init: Callable[[], T]) -> T | The stored instance, created by init() when absent. init runs under the store’s lock |
set(value: T) -> None | Store value under type(value), replacing any previous one |
remove(typ: type[T]) -> T | None | Remove and return the stored instance |
task_store_from_runtime(runtime: object) -> ExtensionData | None
Return the task-scoped store from a LangGraph runtime (request.runtime in a wrap hook, the runtime argument of a lifecycle hook), or None when there is no live task.
EXTENSION_TASK_STORE_KEY
"__deerflow_extension_task_store". The host-owned runtime-context key behind task_store_from_runtime(). Read it only through that helper and never write it.
Task lifecycle
TaskLifecycleContributor
class TaskLifecycleContributor(Protocol):
async def on_task_start(self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo) -> None: ...
async def on_task_stop(
self, app_store: ExtensionData, task_store: ExtensionData, info: TaskInfo, outcome: TaskOutcome
) -> None: ...TaskInfo
Frozen dataclass.
| Field | Type | Default |
|---|---|---|
task_id | str | required |
run_id | str | required |
thread_id | str | required |
kind | Literal["lead", "subagent"] | required |
parent_task_id | str | None | None |
agent_name | str | None | None |
resumed | bool | False |
TaskOutcome
StrEnum: COMPLETED = "completed", ABORTED = "aborted", FAILED = "failed".
System model calls
SystemModelCallObserver
class SystemModelCallObserver(Protocol):
async def on_system_model_call(
self,
app_store: ExtensionData,
task_store: ExtensionData,
kind: SystemOperationKind,
request: SystemModelRequest,
result: SystemModelResult,
) -> None: ...SystemOperationKind
StrEnum: GOAL = "goal", MEMORY = "memory", TITLE = "title", SUMMARIZATION = "summarization".
SystemModelRequest
Frozen dataclass, a read-only snapshot taken before the call.
| Field | Type | Default |
|---|---|---|
messages | Sequence[Any] | () |
model_name | str | None | None |
invoke_config | Mapping[str, Any] | None | None |
messages is normalized to a tuple on construction. A single prompt string becomes a one-element tuple rather than a sequence of characters.
SystemModelResult
Frozen dataclass: response: Any | None = None, error: BaseException | None = None, duration_ms: float | None = None.
Agent assembly
AgentAssemblyObserver
class AgentAssemblyObserver(Protocol):
def on_agent_assembled(self, app_store: ExtensionData, descriptor: AgentAssemblyDescriptor) -> None: ...Synchronous, called at the end of agent construction. Must be cheap and must not raise.
AgentAssemblyDescriptor
Frozen dataclass.
| Field | Type | Default |
|---|---|---|
namespace | str | required |
agent_name | str | required |
requested_model | str | None | required |
effective_model | str | required |
model_parameters | dict[str, Any] | required |
thinking_enabled | bool | required |
reasoning_effort | Any | required |
base_prompt_hash | str | required |
tools | tuple[ToolDescriptor, ...] | required |
middlewares | tuple[MiddlewareDescriptor, ...] | required |
deferred_tool_names | tuple[str, ...] | required |
enabled_skills | tuple[str, ...] | required |
effective_policies | dict[str, Any] | required |
build | dict[str, Any] | {} |
fingerprint: str (cached property) is a canonical_hash of everything that changes behavior. Tools, deferred tool names, and skills are sorted; middleware order is preserved because it decides what wraps what. build and requested_model are excluded, so a redeploy of the same assembly keeps the same fingerprint.
ToolDescriptor
Frozen dataclass: name: str, description_hash: str, schema_hash: str, source: str, mcp_server: str | None = None, mcp_transport: str | None = None.
MiddlewareDescriptor
Frozen dataclass: name: str, module: str, policy_parameters: dict[str, Any] = {}, extension: str | None = None. extension names the contributing extension; it is None for host middleware.
Context compaction
ContextCompactionObserver
class ContextCompactionObserver(Protocol):
async def on_context_compacted(
self, app_store: ExtensionData, task_store: ExtensionData, event: CompactionEvent
) -> None: ...CompactionEvent
Frozen dataclass, captured while both sides of the transform still exist.
| Field | Type |
|---|---|
transform_kind | str |
transform_version | str |
source_content_hashes | tuple[str, ...] |
output_content_hash | str |
compacted_message_count | int |
kept_message_count | int |
Hashes are canonical_hash(message.content) of the content passed directly, never a stringified copy. To match a message to an event, hash its content the same way.
Services
ExtensionService
class ExtensionService(Protocol):
async def start(self, deps: ExtensionRuntimeDeps) -> None: ...
async def stop(self) -> None: ...ExtensionRuntimeDeps
Frozen dataclass passed to start().
| Field | Type | Default |
|---|---|---|
app_store | ExtensionData | None | None |
policy | HostPolicySnapshot | HostPolicySnapshot() |
session_factory | Any | None | None |
run_evidence_reader | RunEvidenceReader | None | None |
run_evidence_reader is None on a host that does not provide one.
Run evidence
RunEvidenceReader
A read-only Protocol. Its default methods raise NotImplementedError.
| Method | Returns |
|---|---|
async list_changed_runs(*, cursor: str | None, limit: int) | RunPage |
async list_run_events(*, thread_id: str, run_id: str, after_seq: int | None, limit: int) | RunEventPage |
async get_run_status(*, thread_id: str, run_id: str) | RunStatusView | None |
The Gateway’s implementation accepts limit from 1 to 2000 and a non-negative after_seq, raising ValueError otherwise. Deletions produce no tombstone: get_run_status() returning None means the run is absent or not visible. See Run Evidence.
RunStatusView
Frozen dataclass: thread_id, run_id, status, created_at, updated_at (all str = ""), error: str | None = None, stop_reason: str | None = None.
RunEventView
Frozen dataclass: thread_id: str = "", run_id: str = "", seq: int = 0 (monotonic within a thread), event_type: str = "", category: str = "", content: Any = None, metadata: dict[str, Any] = {}, created_at: str = "". Content and metadata are detached copies. Content is returned unchanged; metadata has only the legacy auth_token key removed, with no other redaction.
RunPage / RunEventPage
Frozen dataclasses. RunPage: items: tuple[RunStatusView, ...] = (), next_cursor: str | None = None, has_more: bool = False. RunEventPage: items: tuple[RunEventView, ...] = (), next_after_seq: int | None = None, has_more: bool = False.
resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None
Return a reader bound to the authenticated caller of a contributed route. Use it in user-facing routes instead of the global ExtensionRuntimeDeps.run_evidence_reader, which sees every user’s runs. request is duck-typed. Returns None on a host without request-scoped evidence. The Gateway raises PermissionError("run evidence requires an authenticated user with runs:read") when the caller is unauthenticated or lacks the runs:read permission; it never widens an admin or internal caller to global visibility. Any other resolver error propagates.
require_run_evidence_reader(request: object) -> RunEvidenceReader
Like resolve_run_evidence_reader(), but raises NotImplementedError("request-scoped run evidence is unavailable") instead of returning None. Map NotImplementedError to HTTP 503 and PermissionError to 403. It never falls back to the global reader.
RUN_EVIDENCE_READER_RESOLVER_KEY
"deerflow_extension_run_evidence_reader_resolver". The app.state attribute the host installs its request-scoped resolver under. Host-owned.
InvalidRunEvidenceCursor
Subclass of ValueError, raised for a malformed or unsupported cursor, or a cursor from another scope.
Identity
ExtensionPrincipal
Frozen dataclass: user_id: str, is_admin: bool = False, is_internal: bool = False, roles: tuple[str, ...] = ().
resolve_principal(request: object) -> ExtensionPrincipal | None
The authenticated caller of a contributed route, or None when it cannot be determined. request is duck-typed, so a Starlette Request works without the contract depending on Starlette.
require_admin(request: object) -> ExtensionPrincipal
Return the principal if it is an administrator, otherwise raise PermissionError("this endpoint requires an administrator account"). It fails closed when identity cannot be determined.
EXTENSION_PRINCIPAL_RESOLVER_KEY
"deerflow_extension_principal_resolver". The app.state attribute the host installs its resolver under. Host-owned.
require_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal
Return the caller when it holds plugin_management scope for an installed plugin namespace; otherwise raise PermissionError. Use it in a contributed management route before reading (scope="read") or changing (scope="write") the enterprise’s own policy store. The host asks its configured authorization provider with the caller’s trusted identity, so read and write are separate decisions and neither is implied by page access or by a tool grant.
It fails closed: an unknown namespace, an anonymous caller, a missing or failing host resolver, and a non-allowed decision all raise PermissionError. A host that is running on a configuration and can no longer read it follows the configured failure policy — denied under the default fail_closed: true. A host with authorization.enabled: false, or one with no configuration at all, answers “allow”, so the helper is a no-op and does not start rejecting routes; an enterprise that needs an unconditional floor keeps calling require_admin as well. A scope other than "read"/"write", or a namespace outside [a-z][a-z0-9_.-]{0,95}, raises ValueError before any decision.
This form is synchronous. Call it from a FastAPI def endpoint (executed in the thread pool), not from an async endpoint.
arequire_plugin_management(request: object, namespace: str, *, scope: Literal["read", "write"] = "read") -> ExtensionPrincipal
The async form of require_plugin_management, for async def endpoints. It awaits the host’s async resolver and never falls back to the synchronous one, so the host’s configuration read stays off the event loop.
EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY
"deerflow_extension_plugin_authz_resolver". The app.state attribute the host installs the synchronous plugin_management resolver under, answering (request, namespace, scope) -> bool | None. Host-owned.
EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY
"deerflow_extension_plugin_authz_resolver_async". The async counterpart, answering async (request, namespace, scope) -> bool | None. Host-owned.
Message provenance
Middleware that injects messages stamps them, so an observer can tell an injected message from the user’s own without matching on wording.
| Constant | Value |
|---|---|
MESSAGE_CONTENT_KIND_KEY | "deerflow_content_kind" |
MESSAGE_PRODUCER_KIND_KEY | "deerflow_producer_kind" |
MESSAGE_PRODUCER_ENTITY_ID_KEY | "deerflow_producer_entity_id" |
PROVENANCE_KEYS | frozenset of the three keys. The host treats them as server-owned and strips caller-supplied values from untrusted input |
ContentKind
StrEnum: MIDDLEWARE_INJECTION = "middleware_injection", MEMORY = "memory", DURABLE_CONTEXT = "durable_context", SKILL_BODY = "skill_body", IMAGE_PAYLOAD = "image_payload". Stamped values are plain strings, so a kind added by a newer host arrives as an unrecognized string rather than an error.
MessageProvenance
Frozen dataclass: content_kind: str, producer_kind: str, producer_entity_id: str | None = None.
provenance_kwargs(content_kind, producer_kind, *, producer_entity_id=None) -> dict[str, str]
The additional_kwargs fragment to merge into a message you produce. producer_entity_id is omitted when None.
read_provenance(message: object) -> MessageProvenance | None
Read a stamp from message.additional_kwargs. Returns None when either required key is missing or not a string.
Release policies and hashing
ReleasePolicyProvider
@runtime_checkable
class ReleasePolicyProvider(Protocol):
def release_policy_parameters(self) -> dict[str, object]: ...Implement it on a middleware to declare its behavior-affecting parameters. They appear in MiddlewareDescriptor.policy_parameters and in the assembly fingerprint. Values must be JSON-serializable; hash long text instead of embedding it.
collect_release_policies(middlewares: Sequence[object]) -> dict[str, dict[str, object]]
Gather declarations from a stack, keyed by class name (Name, Name#2, … for repeats), unwrapping isolation wrappers. A declaration that raises is recorded as {"error": "<ExceptionType>"}, and one that returns a non-mapping as {"error": "NonMappingDeclaration"}.
canonical_json(value: object) -> str
Deterministic JSON: sorted keys, (",", ":") separators, ensure_ascii=False. Raises TypeError for values that are not JSON-serializable.
canonical_hash(value: object) -> str
SHA-256 hex digest of canonical_json(value) encoded as UTF-8.
Full-stack plugins (experimental)
The plugin contract is experimental in 0.2.4. Always check the return value of
registry.plugin(...). See Plugins.
PluginContribution
Frozen dataclass.
| Field | Type | Default |
|---|---|---|
namespace | str | required |
title | str | required |
description | str | "" |
enabled | bool | False |
fields | tuple[SettingsField, ...] | () |
frontend | BrowserModule | BrowserAssets | None | None |
backend | tuple[BackendAction, ...] | () |
api_version | int | 1 |
tools | tuple[ModelTool, ...] | () |
The host accepts only api_version == 1 and requires at least one of frontend, backend, or tools. Namespaces must be unique.
BrowserModule
Frozen dataclass: module: str, code: str (a self-contained ES module, non-empty and at most 512 KiB), public_fields: tuple[str, ...] = (). Discovery labels this transport inline-v1. Use BrowserAssets when the module needs relative imports, stylesheets, or images.
BrowserAssets
Frozen dataclass declaring a manifest and static files inside the installed package. Discovery labels this transport assets-v1.
| Field | Type | Default |
|---|---|---|
module | str | required |
root | str | Path | required |
manifest | str | "ui_manifest.json" |
public_fields | tuple[str, ...] | () |
root is usually Path(__file__).parent. The manifest, relative to root, must be a JSON object with exactly the keys schema_version (the integer 1), entry, and files:
{
"schema_version": 1,
"entry": "static/dist/index.mjs",
"files": ["static/dist/index.mjs", "static/dist/styles.css"]
}files lists 1 to 256 unique paths and entry must be one of them, ending in .js or .mjs. Paths use ASCII letters, digits, _, -, and ., separated by /, and each segment must start with a letter, digit, _, or -. The root must not be a symlink, and no listed path may pass through one. Limits: 64 KiB for the manifest, 4 MiB per file, 16 MiB in total. Accepted file types: .js, .mjs, .css, .json, .map, .wasm, .png, .jpg, .jpeg, .gif, .webp, .svg, .ico, .woff, .woff2, .ttf, .otf.
registry.plugin() validates the manifest and reads every listed file into an in-memory snapshot. A validation error raises from registry.plugin(), so install() fails. The snapshot’s revision is a SHA-256 over the manifest, the paths, and the file contents, and the Gateway serves the files from that snapshot at GET /api/plugins/{namespace}/assets/{revision}/{path}. Any authenticated user can download listed files, including source maps, even while the plugin is disabled, so never list secrets.
BackendAction
Frozen dataclass: name: str, handler: Callable[[Mapping[str, Any], ActionContext], Awaitable[Any]]. Names must be unique and handlers async.
ModelTool
Frozen dataclass: name: str, description: str, input_schema: Mapping[str, Any] (an inline object schema), handler: Callable[[Mapping[str, Any], ToolContext], Awaitable[Any]], group: str = "extensions".
ActionContext / ToolContext
Frozen dataclasses. ActionContext: principal: ExtensionPrincipal, settings: Mapping[str, bool | int | str]. ToolContext extends it with thread_id: str | None.
SettingsField
Frozen dataclass describing one non-secret deployment setting.
| Field | Type | Default |
|---|---|---|
key | str | required |
title | str | required |
kind | Literal["boolean", "integer", "string"] | required |
default | bool | int | str | required |
description | str | "" |
minimum | int | None | None |
maximum | int | None | None |
max_length | int | 256 |
Version constant
API_VERSION
The host’s contract version as a dotted string, "0.2.4" for the contract this page describes. Always equal to the package version in backend/packages/extension-api/pyproject.toml.
Compatibility rules
-
Additive growth. Every Protocol method has a default implementation and every optional dataclass field has a default. A contract release that adds a method or field does not break extensions built against an earlier one.
-
Before 1.0, a minor release may break extensions and a patch release is additive. From 1.0 on, breaking changes bump the major.
-
The
@extension(api=...)check. When an install function carries a marker, the host refuses it unless:- before 1.0: the same major and minor, and the host’s version is at least the declared one (a
0.2.4host accepts0.2.0through0.2.4, and rejects0.2.5,0.1.x, and0.3.x); - from 1.0: the same major, and the host’s version is at least the declared one.
A marker that is not a dotted numeric string is refused. An install function without a marker is not checked.
- before 1.0: the same major and minor, and the host’s version is at least the declared one (a
-
Package metadata is the primary mechanism: declare
deerflow-extension-api>=0.2,<0.3so the resolver rejects a mismatched host before anything loads. The marker covers installs that bypass resolution. -
Declare frameworks yourself. The contract depends on nothing. An extension that imports LangChain, LangGraph, or FastAPI declares them.
Version history
| Version | PR | Added |
|---|---|---|
| 0.1.0 | #4636 | The foundation: install() and @extension, ExtensionRegistry.middlewares, MiddlewareContributor, MiddlewarePlacement, Placement, AgentScope, AgentBuildContext, HostPolicySnapshot, ExtensionData, task_store_from_runtime, EXTENSION_TASK_STORE_KEY, API_VERSION |
| 0.1.1 | #4684 | task_lifecycle and system_model_observer registrations with TaskLifecycleContributor, TaskInfo, TaskOutcome, SystemModelCallObserver, SystemModelRequest, SystemModelResult, SystemOperationKind |
| 0.1.2 | #4780 | service and routers registrations with ExtensionService and ExtensionRuntimeDeps; the packaged-extension manager |
| 0.2.0 | #4863 | agent_assembly_observer and context_compaction_observer with AgentAssemblyDescriptor, ToolDescriptor, MiddlewareDescriptor, CompactionEvent; message provenance; release policies and canonical hashing; ExtensionPrincipal, resolve_principal, require_admin |
| 0.2.1 | #5405 | ExtensionRuntimeDeps.run_evidence_reader with RunEvidenceReader, RunPage, RunEventPage, RunStatusView, RunEventView, InvalidRunEvidenceCursor |
| 0.2.2 | #5647 | Experimental registry.plugin() with PluginContribution, BrowserModule, BackendAction, ModelTool, ActionContext, ToolContext, SettingsField |
| 0.2.3 | #5727 , #5685 | Request-scoped run evidence: resolve_run_evidence_reader, require_run_evidence_reader, RUN_EVIDENCE_READER_RESOLVER_KEY (#5727). Packaged browser resources: BrowserAssets, and PluginContribution.frontend also accepts it (#5685) |
| 0.2.4 | #5842 | Plugin resource authorization: require_plugin_management, arequire_plugin_management, EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY, EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY |
No release has removed a public name.