| title | Integrations |
|---|---|
| description | Wire Sponsio into your agent framework. |
Sponsio works with any agent framework in Python and TypeScript. Each integration intercepts tool calls at the framework's native hook point. All integrations share the same LTL engine and produce identical block / allow decisions. See tests/cross_language/ for cross-language validation.
| Framework | Factory | Tool wrapping | Lines to add |
|---|---|---|---|
| LangGraph | from sponsio.langgraph import Sponsio |
guard.wrap(tools) |
3 |
| Claude Agent SDK | from sponsio.claude_agent import Sponsio |
guard.hooks() (zero wrapping) |
2 |
| OpenAI SDK | from sponsio.openai import Sponsio (or patch_openai) |
automatic response checks | 2 |
| OpenAI Agents SDK | from sponsio.agents import Sponsio |
guard.wrap(tools) |
3 |
| Vercel AI SDK | from sponsio.vercel_ai import Sponsio |
guard.wrap() (middleware) |
2 |
| CrewAI | from sponsio.crewai import Sponsio |
guard.wrap(tools) |
3 |
| Google ADK | from sponsio.google_adk import Sponsio |
guard.wrap(tools) |
3 |
| MCP | from sponsio.mcp import MCPContractProxy |
proxy.call_tool() |
3 |
| No framework | sponsio.Sponsio(contracts=[...]) |
guard.guard_before() / guard_after() |
3 |
| Framework | Import | Integration |
|---|---|---|
| Claude Agent SDK | @sponsio/sdk/claude-agent |
sponsioHooks(guard) |
| Vercel AI SDK | @sponsio/sdk/vercel-ai |
sponsioMiddleware(guard) |
| OpenAI SDK | @sponsio/sdk/openai |
wrapOpenAI(client, guard) |
| OpenAI Agents SDK | @sponsio/sdk/openai-agents |
wrapAgent(agent, guard) |
| LangChain.js | @sponsio/sdk/langchain |
wrapTools(tools, guard) |
| Google ADK | @sponsio/sdk/google-adk |
wrapTools(tools, guard) |
from langgraph.prebuilt import create_react_agent
from sponsio import contract
from sponsio.langgraph import Sponsio
guard = Sponsio(
agent_id="my_bot",
contracts=[
contract("policy gate before refund")
.assume("called `issue_refund`")
.guarantees("must call `check_policy` before `issue_refund`"),
],
)
agent = create_react_agent(model, guard.wrap(tools))
result = agent.invoke({"messages": [("user", "process refund")]})
guard.print_summary()For existing graphs, use wrap_graph():
guard = Sponsio(config="sponsio.yaml", agent_id="bot")
graph = build_my_graph()
graph = guard.wrap_graph(graph)The pattern is the same for CrewAI, Google ADK, OpenAI Agents SDK, and Vercel AI SDK. Swap the import for the matching sponsio.<framework> namespace and call guard.wrap(tools).
import { ClaudeAgent } from "@anthropic-ai/claude-agent-sdk";
import { Sponsio } from "@sponsio/sdk";
import { sponsioHooks } from "@sponsio/sdk/claude-agent";
const guard = new Sponsio({
agentId: "my_bot",
contracts: ["must call `check_policy` before `issue_refund`"],
});
const agent = new ClaudeAgent({
hooks: sponsioHooks(guard),
});import sponsio
from sponsio import contract
guard = sponsio.Sponsio(
agent_id="my_agent",
contracts=[
contract("identity check before transfer")
.assume("called `transfer_funds`")
.guarantees("must call `verify_identity` before `transfer_funds`"),
],
)
while not done:
tool_name, args = llm_decide_next_action()
result = guard.guard_before(tool_name, args)
if result.blocked:
llm_messages.append(f"Action blocked: {result.det_violations[0].message}")
continue
output = execute_tool(tool_name, args)
guard.guard_after(tool_name, output)Sponsio's tool_policy section lets you declare an allow-list once and have it surface either reactively (the AI tries a denied tool, gets blocked at call time) or proactively (the denied tool never reaches the AI's tool menu).
tool_policy:
default: deny # allow (default) | deny
approved: [search, read_file, list_dir]
enforcement: reactive # reactive (default) | proactiveOr inline:
guard = sponsio.Sponsio(
contracts=[...],
tool_policy={"default": "deny", "approved": ["search"], "enforcement": "proactive"},
)The adapter matrix below reflects the real listing surface each framework exposes. Where an adapter can drop tools before the agent sees them, it does. Where it cannot, the rule still fires reactively via guard_before.
| Adapter | proactive behavior |
|---|---|
| LangGraph, CrewAI, OpenAI Agents SDK, Google ADK | One-shot static filter in guard.wrap(tools). Denied tools never get bound to the agent. Temporal rules (must_precede, count_at_most) still apply reactively at call time. |
| Claude Agent SDK | Hooks-based: the SDK owns the tool list. enforcement: proactive is a no-op here; reactive blocking via guard.hooks() is the supported path. |
| OpenAI SDK, Vercel AI SDK | Per-call by user: filter the tools=[...] array before each request with guard.filter_tools([t.name for t in ALL_TOOLS]) (see custom-loop snippet below). |
| Custom loop (no framework) | Per-turn filter using guard.filter_tools(...) (see snippet below). Catches everything including temporal rules. |
| MCP | MCPContractProxy already reactive-blocks at call_tool. Per-turn filtering of list_tools is on the roadmap. |
guard.filter_tools(candidates) returns the subset of candidate tool names whose call would not be blocked right now. The call is pure (no events, logs, callbacks, or perf samples) and evaluates all contracts including temporal ones. Call it before each LLM turn:
import sponsio
guard = sponsio.Sponsio(
agent_id="my_agent",
contracts=["must call `verify_identity` before `transfer_funds`"],
tool_policy={"default": "deny", "approved": ["verify_identity", "transfer_funds"]},
)
ALL_TOOLS = [verify_identity_tool, transfer_funds_tool, debug_tool, ...]
ALL_NAMES = [t.name for t in ALL_TOOLS]
while not done:
# Per-turn refresh: returns only tools legal under the current trace.
legal_names = set(guard.filter_tools(ALL_NAMES))
legal_tools = [t for t in ALL_TOOLS if t.name in legal_names]
tool_name, args = llm_decide_next_action(messages, tools=legal_tools)
result = guard.guard_before(tool_name, args)
if result.blocked:
messages.append(f"Action blocked: {result.det_violations[0].message}")
continue
output = execute_tool(tool_name, args)
guard.guard_after(tool_name, output)The difference from wrap()-time filtering: filter_tools is called each turn and consults the live trace, so must_precede(A, B) opens B in the menu only after A fires. This is the most thorough proactive option Sponsio offers; it requires you to own the agent loop.
redirect_to_safe(unsafe, safe) substitutes a forbidden tool call with a pre-approved one instead of blocking the agent outright. The model can continue, just not down the unsafe path.
from sponsio import contract
from sponsio.patterns import redirect_to_safe
guard = sponsio.Sponsio(
contracts=[
contract("trash instead of rm")
.guarantees(redirect_to_safe("rm_rf", "trash")),
# Conditional redirect: only large refunds get rerouted.
contract("large refunds go to review")
.assume("called `issue_refund`")
.guarantees(redirect_to_safe("issue_refund", "log_refund_request")),
],
)When the agent calls rm_rf, Sponsio:
- Rolls back the
rm_rfevent from the trace so downstream counters (rate_limit,count_at_most) don't tick on the attempted call. - Surfaces
result.redirected=True+result.redirected_to="trash"fromguard_before. - The adapter invokes
trashwith the model's original arguments. The trace records thetrashcall (via the normalguard_before(safe, args)path), so the audit log reflects what actually executed.
The model sees the safe tool's result, not an error. Substitution is transparent unless the safe tool returns something the model cannot interpret (schema mismatch).
- Both
unsafeandsafemust be registered with your framework. Sponsio does NOT synthesize tools. - The safe tool should accept the same arguments as the unsafe one. If schemas diverge, the adapter passes args verbatim; the user is responsible for compatibility.
- A
redirect → blockedchain (safe tool also violates a different contract) raises a hard block. Sponsio does not chain redirects to avoid loops. - Self-redirect (
unsafe == safe) is rejected loudly viaToolCallBlocked. The pattern factory already rejectsredirect_to_safe("X", "X")at construction; this guard catches the case where a user wiredRedirectToSafe(safe="X")directly viapolicy={}and bound it to a contract that triggers on toolX. - A
redirect → redirectchain (safetool itself has aredirect_to_safecontract pointing elsewhere) is also rejected. Resolve the chain by pointing the originalunsafedirectly at the final safe tool.
If a tool has both a redirect_to_safe contract AND another contract (e.g. must_precede, count_at_most) that fires on the same call, the LangGraph adapter takes the redirect path first before checking for a block. The model never sees the block message because the call gets substituted; the substitute call is then checked against everything else.
This means a must_precede(check_policy, issue_refund) contract paired with redirect_to_safe("issue_refund", "log_refund_request") will effectively skip the ordering check for issue_refund (the call gets redirected to log_refund_request immediately, and must_precede only applies to issue_refund's actual execution which never happens). This is by design: redirecting and refusing are conflicting outcomes, and the redirect was your explicit intent for that tool.
If you want both behaviors, write the must_precede against the safe tool (must_precede(check_policy, log_refund_request)), or use the framework-agnostic guard.guard_before(unsafe_tool, args) inspection in a custom loop where you can branch on check.blocked before check.redirected.
| Adapter | Redirect behavior |
|---|---|
| LangGraph | Built in. wrap() indexes tools by name; on redirect the wrapped ToolNode invokes the safe tool's func / coroutine with the model's original kwargs. Unknown safe tool name raises ToolCallBlocked. |
| CrewAI, OpenAI Agents SDK, Google ADK, Vercel AI, Claude Agent SDK | Surface only: result.redirected_to is set on the CheckResult. Adapter-side dispatch lands in a follow-up release. For now, custom loops can read result.redirected_to and call the substitute tool themselves. |
| Custom loop (no framework) | Read check.redirected_to, look up the safe tool in your registry, call it with the same args. See snippet below. |
# Custom loop pattern that honors redirect_to_safe outcomes
check = guard.guard_before(tool_name, args)
if check.redirected and check.redirected_to:
actual = check.redirected_to
check2 = guard.guard_before(actual, args)
if check2.allowed:
output = registry[actual](**args)
guard.guard_after(actual, output)
elif check.blocked:
messages.append(f"blocked: {check.det_violations[0].message}")
elif check.allowed:
output = registry[tool_name](**args)
guard.guard_after(tool_name, output)guard.hooks() plugs into ClaudeAgentOptions(hooks=...) directly. No tool wrapping needed.
patch_openai() returns a guard whose every client.chat.completions.create(...) is checked automatically. Set SPONSIO_OPENAI_STRICT_TOOL_ARGS=1 to fail closed when the model returns malformed JSON in tool_call.function.arguments. Default warns and degrades.
functools.wraps preserves the original signatures, so ADK's introspection still works. Both sync and async tools are supported. Blocked calls return {"status": "error", "error_message": "BLOCKED..."} instead of executing the wrapped function, so the model sees a normal tool result and can self-correct.
MCP is a tool transport, not an agent framework. Use guard_before() / guard_after() directly, or wrap an MCP client transparently:
from sponsio.mcp import MCPContractProxy
proxy = MCPContractProxy(mcp_client=client, system=system)
result = await proxy.call_tool("send_email", {"to": "user@example.com"})All integrations support loading contracts from a YAML file:
from sponsio.langgraph import Sponsio
guard = Sponsio(config="sponsio.yaml", agent_id="my_bot")See Config yaml reference for the YAML specification.
The trace is append-only during a session. For 24/7 services, call guard.rotate_session() periodically to cap memory and keep the verifier's atom caches fresh.
for turn_idx, user_msg in enumerate(conversation):
response = agent_step(user_msg)
if turn_idx > 0 and turn_idx % 1000 == 0:
guard.rotate_session()Rotation preserves the contract set, perf tracker aggregates, callbacks, and dashboard or OTEL wiring. It clears trace.events, atom caches, violations, and pending liveness obligations. Whole-trace formulas like F(response) cannot survive rotation. rotate_session() flushes pending liveness as violations before wiping. Pass require_finish_session=True to fail loudly when finalisation is skipped.
Pick a cadence: turn-based (every N turns), wall-clock (every T minutes), or semantic (at the natural end of a conversation). Aim for N × avg_tool_calls ≈ 10000 events per window.
from sponsio.integrations.otel import OTelExporter
from sponsio.langgraph import Sponsio
exporter = OTelExporter(endpoint="https://your-otel-backend/v1/traces")
guard = Sponsio(contracts=[...], otel_exporter=exporter)Schema and dashboard wiring: reference/observability.md.