An extension is a reusable feature installed with Agent(extensions=[...]). It can register tools, hooks, middleware, and dynamic prompt context, preserve serializable state, and release resources when the agent closes.

Extensions versus middleware

Interface Responsibility Example
Middleware Transform data in one or more execution stages Normalize a tool argument or shorten a tool result
Hook Observe a lifecycle event Emit a diagnostic metric after a tool finishes
Extension Package capabilities, configuration, state, and lifecycle Install search tools together with normalization and metrics

Use standalone middleware when you only need a transformation. Use an extension when callers should enable a configured feature in one place, especially if it owns resources, has saved state, or combines multiple integration points.

An extension can use middleware. Inside install, call ctx.add_middleware(MyMiddleware(...)). The middleware still runs in the same engine pipeline as directly registered middleware. Being inside an extension does not bypass permissions, change execution ordering rules, or make middleware side effects durable transactions.

Built-in examples demonstrate this composition: LangSmithExtension installs middleware and hooks for tracing; ResultSpillExtension combines result middleware, a read_result tool, stored files, and cleanup.

A complete extension containing middleware

The following extension installs a fixture search tool, normalizes its queries, adds prompt context, and observes completions. No external search service is required to define the feature. The completion count is diagnostic, not an exactly-once audit ledger.

from dataclasses import replace
from typing import Any
from harnessx import (
    Agent, Extension, ExtensionContext, HookContext, HookEvent,
    Middleware, ToolCall,
)

class NormalizeSearch(Middleware):
    async def before_tool_execution(self, call: ToolCall) -> ToolCall:
        query = call.input.get("query")
        if call.name == "search" and isinstance(query, str):
            return replace(call, input={**call.input, "query": query.strip()})
        return call

class SearchExtension(Extension):
    name = "search_feature"
    priority = 50

    def __init__(self, collection: str) -> None:
        self.collection = collection
        self.completed_searches = 0

    def install(self, ctx: ExtensionContext) -> None:
        ctx.register_tool(self.search, replay_policy="safe")
        ctx.add_middleware(NormalizeSearch())
        ctx.on_hook(HookEvent.TOOL_CALL_END, self.record_completion)
        ctx.register_prompt_provider(
            lambda: f"The search tool searches the {self.collection} collection."
        )

    def search(self, query: str) -> str:
        """Return a fixture result from the configured collection."""
        return f"{self.collection}: {query}"

    def record_completion(self, event: HookContext) -> None:
        call = event.data["tool_call"]
        if call.name == "search":
            self.completed_searches += 1

    async def on_save_session(self, ctx: ExtensionContext) -> dict[str, Any]:
        return {"version": 1, "completed_searches": self.completed_searches}

    async def on_load_session(self, ctx: ExtensionContext, state: dict[str, Any]) -> None:
        count = state.get("completed_searches", 0)
        if state.get("version") != 1 or type(count) is not int or count < 0:
            raise ValueError("Invalid search extension state")
        self.completed_searches = count

agent = Agent(extensions=[SearchExtension(collection="product_docs")])
# Run with `async with agent:` once your model provider is configured.

The application only selects SearchExtension; the extension takes care of wiring its pieces. Tools registered without an explicit permission inherit the agent's manager, normally ALLOW. safe is appropriate here because the fixture search has no external effect; choose the replay policy for the real integration separately.

Use a fresh extension instance for each agent. Instances can contain mutable state, registrations, and clients; sharing one across agents is not a supported lifecycle shortcut. Constructor-defined subagents do not inherit parent extensions. Use a manual child wrapper when the child requires its own extension configuration.

Lifecycle and ordering

Every extension needs a unique, nonempty name. priority is an integer, default 100; lower numbers install first, and equal priorities preserve input order. Name collisions are rejected before installation.

Method When it runs Contract
install(ctx) Synchronously during agent construction, after skills Register capabilities; do not acquire async resources
on_turn_start(ctx, user_message) At the start of a user turn Async; may initialize required resources; errors fail the turn
on_turn_end(ctx, final_text) On the successful finish path Async; errors can fail the run
on_turn_complete(ctx, result) Terminal completion, failure, or cancellation Best-effort observer; receives RunResult
on_save_session(ctx) When capturing execution/session state Return JSON-compatible state; keep it fast and side-effect free
on_load_session(ctx, state) When restoring captured state Restore the feature's data; validate its version and shape
teardown() During agent.aclose() Release extension-owned resources

Start, finish, save, and load callbacks follow installation order. Terminal completion and teardown run in reverse installation order. Pauses and approval waits are not terminal completion. Hard process termination can prevent completion callbacks and teardown entirely.

Use async with Agent(...) as agent: to close normally. aclose() attempts all extension teardowns and reports collected cleanup errors. Resource initialization in on_turn_start should be lazy and idempotent: the same agent can handle multiple turns, and durable recovery can repeat lifecycle work. An extension that owns an async client should close it in teardown; an injected, caller-owned client should remain caller-owned unless the API explicitly transfers ownership.

Installation rolls back tool, hook, middleware, and prompt registration mutations when an install fails. It does not roll back arbitrary external effects, arbitrary changes to the agent, or asynchronously acquired resources. Do not open those resources in install; asynchronous teardown is not an installation rollback.

ExtensionContext reference

Use the context's registration methods so the SDK tracks ownership:

Method Purpose
ctx.register_tool(handler, ...) Register an inferred tool or pass input_schema explicitly
ctx.add_middleware(middleware) Add a normal pipeline transformation
ctx.on_hook(event, callback) Subscribe an observer
ctx.register_prompt_provider(callback) Append dynamic text to the system prompt
ctx.get_state(key, default) / ctx.set_state(key, value) Access extension metadata

The four registration methods return disposable handles. handle.close() removes the registration; closing twice is harmless. On agent shutdown, registrations made through these methods are removed in reverse order for each extension. Registration through ctx.agent.tools or other direct mutations does not receive this automatic ownership tracking. Duplicate tools require an intentional replace=True; tracked replacement restores the previous binding when removed if the extension still owns the active binding.

ctx.register_tool supports name, description, input_schema, permission, concurrent, replay_policy, timeout_seconds, and replace. Permission defaults are the same as other registration paths. A tool installed by an extension is subject to the same engine checks as any other tool.

Context properties include config, tools, hooks, middleware, memory, session_id, and agent. The context is a convenience interface, not a security sandbox: extensions are trusted Python code with access to the agent. Use ctx.agent explicitly for advanced access; implicit forwarding of unknown context attributes is deprecated.

Prompt providers are synchronous callbacks evaluated while building a model request's system prompt. Their exceptions are currently ignored, so a prompt provider is not a place for mandatory authorization or configuration checks. The get_state/set_state metadata dictionary is shared; prefix keys with your extension's name to avoid collisions. Save/load callback output is stored under the extension name and is the preferred contract for structured feature state.

Persistence and durable execution

Save state as JSON-compatible dictionaries, lists, strings, booleans, finite numbers, and null values. Do not serialize clients, locks, handlers, file objects, or credentials. Use a version field and validate on restoration, as in the example.

on_save_session is used for engine snapshots as well as explicit session saving; it can run many times during a turn. on_load_session can run during recovery. Neither callback is a one-time startup/shutdown signal. Reconstruct resources from application configuration instead of persisting their Python objects.

When restoring an agent with Agent.load_session, supply the extension bindings again through extensions=[...], along with provider/tool bindings where needed. Required saved extension names must be present. Durable runtime factories similarly need consistent, versioned bindings; snapshots do not serialize extension code.

Lifecycle callbacks and middleware can be repeated on recovery. External writes inside a callback do not inherit tool execution keys, replay policy, or an exactly-once guarantee. Put managed business effects into tools and enforce required checks in the execution path. Hooks and on_turn_complete are best-effort observation; their failure is not a reliable signal to stop an effect.

Built-in extensions

Result spilling

from harnessx import Agent, ResultSpillExtension

agent = Agent(extensions=[ResultSpillExtension(threshold_bytes=32_000)])

Large tool results become compact envelopes while the extension stores their contents and exposes read_result for slices, searches, and supported tabular operations. The original tool need not execute again to inspect a stored result. This feature needs more than a result transform: it also needs a tool, files, persistence integration, and cleanup, which is why it is an extension.

Spill files are temporary by default. Snapshot and durable artifact bindings preserve captured data beyond cleanup. A path in arbitrary tool text is not automatically a portable artifact, and a spill reference is not evidence of business validity or provenance. Distributed workers need shared artifact storage and explicit retention. Incident export controls optional artifact inclusion.

LangSmith tracing

LangSmithExtension combines hooks and middleware to produce connected agent, model, and tool spans. It requires the optional LangSmith dependency and service configuration for uploads. See examples/langsmith_tracing.py for the full example, including a child agent with its own tracing extension. Tracing observes execution; it does not replace durable recording or permission enforcement.

Each model span records what was sent: the system prompt, the messages, the tool definitions, and the invocation parameters (model, max_tokens, temperature, streaming), plus the prompt-cache prefix_key. On completion it records the response, the stop reason, and usage with cache-read, cache-creation, and reasoning token details, and it carries ls_model_name and ls_provider so LangSmith can price the run.

Test an extension before reuse

Use a scripted provider to verify installation, transformations, and tool calls without a live model. Exercise successful and failed runs, cancellation, repeated turns, snapshot restoration, and cleanup. Assert that registered tools and middleware are removed when the extension closes and that an interrupted external effect is handled by the executor, not inferred from a hook counter.

See middleware and hooks for all transformation stages, and flight recording for incident export and artifact inclusion.