Skip to content

Attach anywhere

Write a tool once. Call it from plain Python, a web app, a worker, any agent framework, or another language. Every surface goes through the same Executor, so the same safety rules apply everywhere.

One call: attach()

attach gives a framework its native tools. Name the framework, or pass one of its objects.

registry = Registry()


@registry.tool
def shipping_rate(region: str) -> float:
    """Shipping rate for a region, in percent of the order value."""
    return {"eu": 5.0, "us": 18.0}.get(region, 12.0)


executor = Executor(registry, approval=AutoDeny())  # the safety rules, for every framework

tools = attach("openai", registry, executor=executor)  # by name: Chat Completions tools
# tools = attach(agent, registry, executor=executor)   # by object: agents.Agent, StateGraph...
You pass attach does
a name: "langgraph", "openai-agents", "pydantic_ai" finds the integration by entry point; case, - and _ do not matter; the extra's name works too
an object, class or module: an agents.Agent, a StateGraph, import agents finds the framework that owns its class, or a base class
registry=None uses default_registry, the one @tool fills
executor=None uses the process-wide default executor; pass yours for your hooks and ports

It returns the integration's export_tools(registry, executor=...): the native tools for every tool exported to that target (FunctionTools, tool dicts, one ToolNode per tool for LangGraph). Discovery is by entry point, so a new integration, or your own plugin, works with no change here.

attach never takes an approval. approval=True, requires_approval=, confirm= and similar raise ToolUsageError. Approval belongs to the Executor. A framework's own "approved" never counts alone; a bridge passes only a one-use token the adapter issued.

try:
    attach("openai-agent", registry)  # a typo
except IntegrationError as error:
    print(str(error).split(" (installed:")[0])  # the rest lists every integration
try:
    attach("openai", registry, approval=True)  # never: approval is the executor's
except ToolUsageError as error:
    print(str(error).split(":")[0])

An unknown name suggests the closest one and lists every integration: installed, or with its pip install "agent-adapter[extra]" line. A known framework whose extra is missing raises MissingDependency with that line. agent-adapter doctor prints the same list, and agent-adapter init writes a starter project (CLI).

flowchart LR
  T["your function<br/>@tool · registry.wrap(fn)"] --> R[("Registry")]
  R --> X["Executor<br/>guards · approval · budget<br/>cache · ledger · events"]
  X --> P["plain code<br/>run · run_many · run_sync"]
  X --> W["web apps and workers<br/>FastAPI · Flask · Django · Celery"]
  X --> A["attach(name or object)<br/>any agent framework"]
  X --> S["other languages<br/>MCP · A2A · HTTP server · TS client"]

Pick your entry point

attach covers the tools. A framework's module adds its approval resolver or flow compiler.

Your situation Entry point Page
A plain function or script @registry.tool or registry.wrap(fn), then Executor.run / run_many Minimal path
An existing service or class registry.wrap(obj.method), one per method Wrap a legacy class
FastAPI or another async app await executor.run(...) Sync code and backends
Flask, Django (WSGI), Celery, cron run_sync(executor.run(...)) Sync code and backends
A queue, a Lambda or a data pipeline handle_envelope, tool_handler, run_many Queues, Serverless, Pipelines
OpenAI (Chat, Responses) raw API chat_tools(registry), responses_tools(registry); each tool call via executor.run_call OpenAI
Anthropic raw API anthropic_tools(registry), executor.run_many(calls), tool_result_blocks(results) Anthropic
Gemini / google-genai function_declarations, afc_callables, run_function_calls Gemini
LangGraph tool_node(registry), compile_flow(flow) LangGraph
Google ADK adk_tools(registry), resolve_confirmations ADK
OpenAI Agents SDK function_tools(registry), resolve_interruptions OpenAI Agents SDK
Pydantic AI pydantic_ai_tools(registry), resolve_deferred Pydantic AI
Microsoft Agent Framework agent_framework_tools(registry), resolve_approvals Agent Framework
Agno agno_functions(registry), resolve_requirements Agno
Strands Agents strands_tools(registry), strands_approval_hook Strands
Haystack haystack_tools(registry), confirmation_hook Haystack
Claude Agent SDK claude_sdk_bridge(registry, port) Claude Agent SDK
Semantic Kernel kernel_plugin(registry), approval_filter Semantic Kernel
AG2 ag2_tools(registry); ag2_tool(spec, approval=port) for a gate AG2
Tools only: LangChain, LlamaIndex, CrewAI, DSPy, smolagents, AutoGen 0.7, CAMEL-AI attach("<name>"), or the module's *_tools(registry) LangChain, LlamaIndex, CrewAI, DSPy, smolagents, AutoGen, CAMEL-AI
A UI speaking AG-UI agui_endpoint(AGUIBridge(...), agent) AG-UI
A REST service with an OpenAPI spec import_openapi(registry, spec, base_url=...) OpenAPI
A framework not listed here the Integration protocol and its entry point; then attach finds it Writing an integration
Another agent, over A2A a2a_app(registry, url=...); import_a2a for a remote agent's skills A2A
Another language, or an MCP client streamable_http_app(registry) at /mcp MCP
Another language over HTTP, with reviews create_app(registry), the TypeScript client HTTP server, TypeScript client
A non-Python framework or a hosted agent MCP or REST, per stack Connect anywhere

The minimal path for non-agent code

No model and no framework. Three steps.

1. Define or wrap the tool. New code gets the decorator; existing code is wrapped unchanged.

registry = Registry()


@registry.tool(kind="search", parallel_safe=True, timeout=10)
def find_article(article: str) -> str:
    """Return the text of a help-center article.

    Args:
        article: Article id, e.g. HC-101.
    """
    return f"Article {article}: how to fix it"


def delete_notes(user_id: str) -> int:
    """Delete a user's saved notes; returns how many were deleted."""
    return 3


registry.wrap(delete_notes, confirm=True, effects="destructive")  # existing code, unchanged

2. Build one executor. It holds the policies. Make it once per process.

executor = Executor(
    registry,
    hooks=[LoggingHook()],  # one redacted log line per call, once logging is configured
    approval=AutoDeny(reason="no reviewer in this script"),  # confirm tools fail closed
    limits=RunLimits(max_calls=20),  # at most 20 calls per run
)

The adapter never configures log handlers. Call logging.basicConfig(level=logging.INFO) (or your app's logging setup) to see the LoggingHook lines.

3. Run. run returns the value or raises; run_many runs a batch concurrently and keeps input order. From sync code, wrap the call in run_sync.

print(await executor.run("find_article", {"article": "HC-101"}))
calls = [ToolCall(name="find_article", arguments={"article": a}) for a in ("HC-102", "HC-201")]
print([r.value for r in await executor.run_many(calls)])
try:
    await executor.run("delete_notes", {"user_id": "u-1"})
except ApprovalDenied as denied:
    print("denied:", denied.reason)
# A Flask view, a Celery task or a script: no event loop of its own.
return str(run_sync(executor.run("find_article", {"article": "HC-301"}), timeout=30))

The same spec renders for any model API. Nothing is redefined:

spec = registry.get("find_article")
print(json.dumps(chat_tool(spec)["function"]["parameters"]["required"]))
print(json.dumps(sorted(anthropic_tool(spec))))

Safety you get everywhere

The executor applies these on every call, from every surface above.

Safety What you set What happens Page
Approval confirm=True or a rule, plus an ApprovalPort The port approves, denies, edits or defers. No port: denied (fails closed). A framework's own "approved" signal never counts alone: a bridge passes an approval only as a one-use token the adapter issued after its own port approved, bound to the run, the native call id, the tool and a digest of the arguments. A bridge cannot issue a token for a call with no native call id; such a call is denied by the bridge or decided by the executor's port. One opt-in exception: an MCP server built with client_approval=True makes the MCP client's user the approver instead of the port (MCP approval); leave it off where a server-side reviewer is the control Approval, HITL how-to
Budgets BudgetHook(max_usd, scope="run" \| "thread" \| "global") Stops (or warns) before a tool or model call once spend reaches the cap Hooks, Budgets how-to
Run guards once_per_run=True, RunLimits(max_calls=...) Extra calls fail with RunLimitExceeded; a replay of the same call is not a new call Executor policies
Concurrency caps parallel_safe, RunLimits(per_kind={...}) Exclusive tools run alone; each kind has a slot cap per run Executor policies
Rate limits RateLimitHook({"search": Rate(5, 1.0)}) Calls wait for a token; they never fail Hooks
Timeouts, retries timeout=, retries= Each attempt is timed; retryable errors back off Executor policies
Cost ledger Executor(ledger=Ledger()) One LedgerEntry per tool call and per model call made through executor.model(port), priced from the table Cost
Tracing Executor(sink=EventLog()), OTelSink run.*, tool.*, approval.*, llm.* events; GenAI spans Tracing
No secrets in logs LoggingHook() Secret-looking keys redacted; confirm tool arguments, results and exception text never logged Hooks
Safe errors raise ToolError for the model Foreign exceptions reach the model as tool-failed with no message text Errors

Over HTTP the server adds run ownership: call ids are scoped to the caller, the first principal to use a run owns it, and other principals get 403 on its invokes, events, cost and ?run_id= approval listings, and 404 on its approval records. By default only the owner or a holder of the approvals:decide scope can decide an approval. See the security model.

Files

File Does
examples/attach_any.py attach() by name, and its two first errors
examples/howto_attach.py this page's code: one registry, one executor, every call style
tests/docs/test_attach_examples.py, tests/integrations/test_attach.py run attach offline: names, objects, errors, no approval option
tests/docs/test_howto_examples.py runs howto_attach.py offline and checks the output