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.

This page is about agents that act for your users at run time. For AI coding tools (Claude Code, Codex, Cursor, Copilot, Gemini CLI, Windsurf) that write and review rules in your repository, see AI coding tools and the MCP server.

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.

Sequence diagram, described in Mermaid: sequenceDiagram participant U as User participant M as Model participant H as Your tool host participant R as Runtime or rule server U->>M: Send 30,000 to Sam M->>H: evaluate_rules(ruleset, entity, operation, data_json) H->>R: evaluate with the actor from the session R-->>H: decision deny, ORG-TRF-002 (risk-officer may accept) H-->>M: the evaluation result, unchanged M-->>U: Not done: over the 25000 limit, a risk officer must accept it

How the model reads the tool is its description, from llm-tools.json:

bindings/llm-tools.json
"name": "evaluate_rules",
"strict": true,
"description": "Check a proposed operation against the business rules without changing anything. Returns decision (allow or deny), findings with severity, field pointers and location (the page, screen, section and component the rule names), field effects and computed values. Always call this before calling an API operation that creates, updates or deletes the entity, and never present a denied operation as done.",
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. It is an OpenAI Structured Outputs format; its head names it and turns strict mode on:

bindings/rule-draft.response-format.json
"type": "json_schema",
"name": "rule_draft",
"strict": true,

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.

On this page