Framework adapters
These recipes are the adapter contracts we want ecosystem packages to preserve. They keep Sanction outside the framework's identity system and inside the pre-action path: the framework asks, Sanction decides, the agent acts only on an approved decision or redeemed grant.
TypeScript: SanctionMiddleware
Use this shape for LangChain.js, LangGraph, Mastra, Vercel AI SDK tool wrappers, or any custom agent runtime:
import { SanctionClient } from "@sanction/sdk"
type ToolCall = {
server: string
tool: string
input?: unknown
run: () => Promise<unknown>
}
export function SanctionMiddleware(client: SanctionClient) {
return async function runTool(call: ToolCall) {
const decision = await client.authorizeTool({
server: call.server,
tool: call.tool,
input: call.input,
})
if (decision.status === "approved") return call.run()
if (decision.status === "escalated") {
throw new Error(`Sanction escalation required: ${decision.requestId}`)
}
throw new Error(`Sanction denied ${call.tool}: ${decision.code ?? decision.reason}`)
}
}
The concrete package still needs SDK support for authorizeTool; until then,
call POST /api/v1/authorize/tool directly with the agent key. The important
adapter invariant is stable: tool execution is behind the decision, not beside
it in a log.
Python: LangChain / LangGraph callback
Wrap tool execution in a callback or runnable decorator:
import os
import httpx
SANCTION_API = os.getenv("SANCTION_API", "https://getsanction.com/api/v1")
SANCTION_KEY = os.environ["SANCTION_AGENT_KEY"]
def authorize_tool(server: str, tool: str, payload: dict | None = None) -> dict:
res = httpx.post(
f"{SANCTION_API}/authorize/tool",
headers={"x-api-key": SANCTION_KEY},
json={"server": server, "tool": tool, "input": payload or {}},
timeout=10,
)
res.raise_for_status()
return res.json()
def sanctioned_tool(server: str, tool: str, fn):
def wrapper(*args, **kwargs):
decision = authorize_tool(server, tool, {"args": args, "kwargs": kwargs})
if decision["status"] == "approved":
return fn(*args, **kwargs)
if decision["status"] == "escalated":
raise RuntimeError(f"Sanction escalation required: {decision['request_id']}")
raise RuntimeError(f"Sanction denied {tool}: {decision.get('code') or decision.get('reason')}")
return wrapper
Use the same pattern for spend (/authorize), provisioning
(/authorize/provision), and capability acquisition (/authorize/capability).
LiteLLM callback
For token spend, the simplest path is still the gateway base URL. If LiteLLM is already the gateway of record, add a callback that logs usage and uses Sanction as the budget authority for high-risk operations:
import os
import httpx
SANCTION_API = os.getenv("SANCTION_API", "https://getsanction.com/api/v1")
SANCTION_KEY = os.environ["SANCTION_AGENT_KEY"]
def log_tokens(model: str, tokens_in: int, tokens_out: int, cost_usd: float, task: str):
httpx.post(
f"{SANCTION_API}/tokens",
headers={"x-api-key": SANCTION_KEY},
json={
"model": model,
"tokens_in": tokens_in,
"tokens_out": tokens_out,
"cost_usd": cost_usd,
"task": task,
},
timeout=10,
).raise_for_status()
For a fail-closed budget wall before the provider call, route LiteLLM's provider
base URL through /api/gateway/<provider> with x-sanction-key. Manual
/tokens logging is useful for parity and reporting; it is not a pre-call wall.
Adapter checklist
- Fail closed on missing/invalid Sanction credentials.
- Treat
deniedas a normal planning outcome, not an exception to retry blindly. - Treat
escalatedas a wait-for-grant state. - Pass idempotency keys for retried actions.
- Store or link
request_idso evidence can be replayed later. - Keep provider keys in the runtime only until gateway vault-injected keys ship.