Your first governed tool
The @governed decorator is the single-function entry point. Wrap any async (or sync) function with it and the policy gate evaluates every call before the function body runs. Denied calls raise GovernanceError with the full policy decision attached.
Setup
You need OPA reachable at http://localhost:8181 — see Installation for the options. Then:
from kitelogik import (
GovernanceError,
OPAClient,
PolicyGate,
SessionContext,
governed,
)
gate = PolicyGate(opa_client=OPAClient()) # default base_url
context = SessionContext(
session_id="example_01",
user_role="support_agent",
session_scopes=["read_customer", "approve_refund_under_100"],
)SessionContext is created once per session and shared across every governed call in that session.
Wrap a function
@governed(gate=gate, context=context)
async def approve_refund(customer_id: str, amount: float) -> str:
return f"Refunded ${amount:.2f} to {customer_id}"That's the entire integration. The function body is unchanged. The decorator:
- Builds a
ToolCallInputfrom the function name and the call'sargs - Evaluates it against your Rego policies via
PolicyGate - If allowed, runs the function body and returns the result
- If denied, raises
GovernanceError(message, decision=PolicyDecision(...))
Call it
import asyncio
async def main():
# Within scope — runs normally
print(await approve_refund(customer_id="cust_001", amount=42.0))
# Out of scope — policy denies
try:
await approve_refund(customer_id="cust_001", amount=5000.0)
except GovernanceError as exc:
print(f"BLOCKED: {exc}")
print(f" risk_tier = {exc.decision.risk_tier}")
print(f" rule_matched = {exc.decision.rule_matched}")
asyncio.run(main())Output (with the default kitelogik init policy):
Refunded $42.00 to cust_001
BLOCKED: Governance denied tool call: Refunds over $200 require manager approval
risk_tier = OPERATIONAL
rule_matched = data.kitelogik.main.deny[_]Sync vs async, override the action name
@governed accepts both async and sync functions. It also accepts an optional action= argument to override the policy-layer name when your Python function name differs from what your Rego policies match against:
@governed(gate=gate, context=context, action="approve_refund")
async def issue_partial_refund(customer_id: str, amount: float) -> str:
...When to reach for GovernedToolbox instead
@governed is one tool, one decoration. For agent loops that register many tools and dispatch by name, the GovernedToolbox is friendlier:
from kitelogik import GovernedToolbox
toolbox = GovernedToolbox(gate=gate, context=context)
toolbox.register("get_customer", get_customer)
toolbox.register("approve_refund", approve_refund)
result = await toolbox.call(
"approve_refund",
{"customer_id": "cust_001", "amount": 42.0},
)Every framework adapter (OpenAI, LangChain, CrewAI, …) is a thin layer over GovernedToolbox — same governance, same audit log, framework-shaped ergonomics. See adapters overview.
Important behaviour: hard deny vs HITL vs scope deny
GovernanceError is raised on any non-allow decision — that includes hard deny, soft deny, and requires_hitl=True. To distinguish them in calling code, inspect exc.decision:
try:
await approve_refund(customer_id="cust_001", amount=5000.0)
except GovernanceError as exc:
if exc.decision.deny:
... # hard block — no recourse
elif exc.decision.requires_hitl:
... # human review pending — see HITL queue patterns
else:
... # soft deny — agent can adjust args and retryFramework adapters convert these into a structured [BLOCKED] string by default so the agent loop can continue without an unhandled exception — @governed does not, because the caller is plain Python that should handle the failure explicitly.
Next
- Your first policy — write a YAML rule, compile to Rego, deploy
- Governance events — the full event schema your policies receive
- Adapters → Overview — the framework-specific ergonomic layer over the same engine