Rule Cascade
Playbooks

AI agent tools

Give an LLM agent list_rules, evaluate_rules and explain_rule so it checks before it acts - without letting it decide, name its own roles, or accept its own risks.

bindings/llm-tools.json defines three strict function tools. Your program is the tool host: it receives a call, asks a runtime or the rule server, and returns the answer.

ToolArgumentsHost answers with
list_rulesruleset, entity, operationThe rules of the server manifest for that entity and operation
evaluate_rulesruleset, entity, operation, data_json, original_json, view, resolutionsThe evaluation result, unchanged
explain_ruleruleset, rule_idOne rule of the server manifest

The actor is not an argument. The host takes it from its own authentication: a model that could name its own roles could accept its own risks.

Steps

Give the model the tools

The file is in the shape of the OpenAI Responses API and written for strict mode. Send its tools array as it is. For Chat Completions, wrap each one as {"type": "function", "function": {name, description, parameters, strict}}. As MCP tools, use parameters as the inputSchema.

import json

tools = json.load(open("bindings/llm-tools.json"))["tools"]    # drop the top-level $comment

Implement the host

Against a runtime in process (here Python, run from the repository root with packages/python/src on PYTHONPATH):

import json
from pathlib import Path

from rule_cascade import RuleSet

rulesets = {p.name[:-len(".bundle.json")]: RuleSet.from_bundle(json.loads(p.read_text(encoding="utf-8")))
            for p in Path("conformance/bundles").glob("*.bundle.json")}


def list_rules(ruleset, entity, operation):
    return [{"id": r["id"], "kind": r["kind"], "title": r.get("title"), "severity": r.get("severity")}
            for r in rulesets[ruleset].manifest("server")["rules"]
            if r["target"].get("entity", entity) == entity and (operation is None or operation in r["operations"])]


def evaluate_rules(actor, ruleset, entity, operation, data_json, original_json, view, resolutions):
    return rulesets[ruleset].evaluate({
        "entity": entity, "operation": operation,
        "data": json.loads(data_json),
        "original": json.loads(original_json) if original_json else None,
        "view": {k: v for k, v in view.items() if v is not None},
        "resolutions": [{k: v for k, v in r.items() if v is not None} for r in resolutions],
        "actor": actor,                       # from the host's authentication, never from the model
    })


def explain_rule(ruleset, rule_id):
    return next((r for r in rulesets[ruleset].manifest("server")["rules"] if r["id"] == rule_id), None)

Against the rule server instead: list_rules is GET /rulesets/{ruleset}/manifest?channel=server filtered the same way, evaluate_rules is POST /evaluations, explain_rule is GET /rulesets/{ruleset}/rules/{rule_id}, all with the server token.

Dispatch the calls, with the actor from your session

HANDLERS = {"list_rules": list_rules, "evaluate_rules": evaluate_rules, "explain_rule": explain_rule}

def handle_tool_call(name, arguments_json, session_actor):
    arguments = json.loads(arguments_json)
    if name == "evaluate_rules":
        return json.dumps(evaluate_rules(session_actor, **arguments))
    return json.dumps(HANDLERS[name](**arguments))

A domestic transfer of 30,000 proposed by an agent acting for a teller comes back denied, with two findings (summarised: decision, then code, resolution and acceptableBy of each):

deny  ORG-TRF-002 resolution=accept-risk acceptableBy=['risk-officer']
      PAY-TRF-003 resolution=acknowledge

Keep the agent honest

  • evaluate_rules checks; it does not perform. The API operation that follows evaluates again on the server, whatever the tool returned.
  • Return the result unchanged: decision, blocking, resolution and acceptableBy tell the model what the user may do next.
  • A resolution comes from the user. Ask before sending acknowledge; never let the model write the justification of an accept-risk.
  • The server manifest contains server-only rules. If the model's output reaches people who must not learn them, answer list_rules and explain_rule from manifest("client") instead.
  • A ruleset with custom operators needs them registered in the host's runtime.

Optional: let the model draft rules

bindings/rule-draft.response-format.json constrains a model to one syntactically valid validation rule. A draft is not a rule until it passes the schema, the load checks, a golden test and review, like any other: see drafting a rule.

Done when the agent calls evaluate_rules before every create, update or delete; the actor in every evaluation comes from your session; a denied operation is never reported as done; and the API's own server evaluation still runs after the tool call.

Source: site/content/docs/playbooks/ai-agent-tools.mdx

On this page