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.

Reach for an extension when callers should enable a configured feature in one place, especially when it owns resources, has saved state, or combines several integration points. For the choice between a standalone transformation and a packaged feature, see middleware and hooks.

An extension can use middleware. Inside install, call ctx.add_middleware(MyMiddleware(...)). That middleware runs in the same engine pipeline as directly registered middleware. It does not bypass permissions or change ordering rules. The built-ins work this way: LangSmithExtension installs middleware and hooks for tracing, and ResultSpillExtension combines result middleware, a read_result tool, stored files, and cleanup.

A complete extension containing middleware

This extension installs a search tool, normalizes its queries, adds prompt context, and observes completions. The caller selects one extension and gets all four.

from dataclasses import replace
from harnessx import Agent, Extension, ExtensionContext, HookEvent, Middleware, ToolCall

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

class SearchExtension(Extension):
    name = "search_feature"

    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.log_completion)
        ctx.register_prompt_provider(lambda: "search covers the product docs.")

    def search(self, query: str) -> str:
        """Search the product docs collection."""
        return f"product_docs: {query}"

    def log_completion(self, event) -> None:
        print("tool finished:", event.data["tool_call"].name)

agent = Agent(extensions=[SearchExtension()])

Tools registered without an explicit permission inherit the agent's manager, normally ALLOW. safe suits a search with no external effect; choose the replay policy for the real integration separately.

Use a fresh extension instance for each agent. Instances hold mutable state, registrations, and clients, so do not share one across agents. Constructor-defined subagents do not inherit parent extensions. Use a manual child wrapper when the child needs 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 and knowledge 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 skip completion callbacks and teardown entirely.

Use async with Agent(...) as agent: to close normally. aclose() attempts every extension teardown and reports the cleanup errors it collected. Keep resource initialization in on_turn_start lazy and idempotent: one agent can handle many turns, and durable recovery can repeat lifecycle work. An extension that creates an async client should close it in teardown. An injected, caller-owned client stays caller-owned unless the API explicitly transfers ownership.

A failed install rolls back its tool, hook, middleware, and prompt registrations. It does not roll back external effects or asynchronously acquired resources. Do not open those resources in install.

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

These four registration methods return disposable handles. handle.close() removes the registration, and closing twice is harmless. On agent shutdown, each extension's registrations are removed in reverse order. Registering through ctx.agent.tools or other direct mutation gets no ownership tracking. Duplicate tools require an explicit replace=True; a tracked replacement restores the previous binding when removed, as long as the extension still owns the active binding.

ctx.register_tool supports name, description, input_schema, permission, concurrent, replay_policy, timeout_seconds, retry, retry_if_result, and replace. Permission defaults match other registration paths, and an extension's tool passes 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 request's system prompt. Their exceptions are ignored, so put authorization and configuration checks elsewhere. The get_state/set_state metadata dictionary is shared, so prefix keys with your extension's name. Save and 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. Include a version field and validate the version and shape on restore.

on_save_session runs for engine snapshots as well as explicit session saving, so it can run many times during a turn. on_load_session can run during recovery. Neither is a one-time startup or shutdown signal. Rebuild resources from application configuration rather than persisting their Python objects.

Put managed business effects into tools and enforce required checks in the execution path. Lifecycle callbacks and middleware can repeat on recovery, and an external write inside a callback gets no execution key or replay policy. Hooks and on_turn_complete are best-effort observation.

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 run again to inspect a stored result. The feature needs a tool, files, persistence integration, and cleanup, which is why it is an extension. See flight recording for how spilled artifacts are retained and exported.

LangSmith tracing

LangSmithExtension combines hooks and middleware to produce connected agent, model, and tool spans. Model spans carry the request, the response, the stop reason, token usage, and the ls_model_name and ls_provider fields LangSmith uses for pricing. It requires the optional LangSmith dependency and service configuration for uploads. See examples/07-quality/tracing_with_langsmith.py for the full example, with a span for each model call and tool call.

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