Skip to content

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:

python
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

python
@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:

  1. Builds a ToolCallInput from the function name and the call's args
  2. Evaluates it against your Rego policies via PolicyGate
  3. If allowed, runs the function body and returns the result
  4. If denied, raises GovernanceError(message, decision=PolicyDecision(...))

Call it

python
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):

text
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:

python
@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:

python
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:

python
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 retry

Framework 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

Released under the Apache 2.0 License.