diff --git a/docs.json b/docs.json
index 41a28f7..e3eddd9 100644
--- a/docs.json
+++ b/docs.json
@@ -109,7 +109,8 @@
"group": "Use case examples",
"pages": [
"examples/customer-support",
- "examples/steer-action-demo"
+ "examples/steer-action-demo",
+ "examples/attribute-based-controls"
]
}
]
@@ -170,7 +171,8 @@
"group": "Use case examples",
"pages": [
"examples/customer-support",
- "examples/steer-action-demo"
+ "examples/steer-action-demo",
+ "examples/attribute-based-controls"
]
}
]
diff --git a/examples/attribute-based-controls.mdx b/examples/attribute-based-controls.mdx
new file mode 100644
index 0000000..294c60f
--- /dev/null
+++ b/examples/attribute-based-controls.mdx
@@ -0,0 +1,247 @@
+---
+title: Attribute-Based Controls
+description: Apply rules only for some values of a request attribute, such as the country. Author them as one control per value or as one composite control.
+---
+
+Some rules apply only when a request attribute has a given value. The attribute can be the user's country, tenant, user tier or channel. Agent Control expresses this as a condition on the attribute, combined with the rule itself.
+
+This example uses the country (jurisdiction) as the attribute. An assistant must not return a German IBAN to a user in Germany, or a US Social Security number to a user in the US. To use another attribute, replace the jurisdiction field and its values.
+
+## What This Demonstrates
+
+- Attribute-based rules: each rule applies only when the request's jurisdiction matches.
+- Two ways to author the same policy:
+ - One control per attribute value, here one per country.
+ - One composite control with a branch for each value.
+- Baseline controls that apply to every country, next to the country controls.
+- Two ways to send the jurisdiction with each check:
+ - In `step.context`, with `evaluate_controls()` or the HTTP API.
+ - As a function argument, with the `@control()` decorator.
+
+No LLM or API key is needed. The scripts use fixed text in place of a model answer.
+
+## Quick Start
+
+### Prerequisites
+
+1. Start the Agent Control server: `make server-run` from the repo root.
+
+### Run the Demo
+
+```bash
+# From the agent-control repo root
+uv run python examples/attribute_controls/attribute_controls_context.py
+uv run python examples/attribute_controls/attribute_controls_decorator.py
+```
+
+Each script creates its agent and controls, then runs the scenarios. It prints PASS or FAIL for each check, and exits non-zero if any check fails. The scripts are safe to re-run.
+
+## Send the Jurisdiction
+
+The caller must send the jurisdiction with each check. Use an ISO 3166-1 alpha-2 code, such as `DE` or `US`.
+
+If the country code arrives in a request header, such as `x-ai-jurisdiction: DE`, the caller copies it into `step.context.jurisdiction`. Agent Control does not map request headers into the step.
+
+### In `step.context` (recommended)
+
+`context` holds request metadata, separate from the data that the controls check. Controls select the value with the path `context.jurisdiction`.
+
+```python
+result = await agent_control.evaluate_controls(
+ "support-answer",
+ input="What is on file for me?",
+ output=answer_text,
+ context={"jurisdiction": "DE"},
+ step_type="llm",
+ stage="post",
+ agent_name=AGENT_NAME,
+)
+if not result.is_safe:
+ ... # blocked
+```
+
+Gateways and non-Python clients send the same field in the body of `POST /api/v1/evaluation`:
+
+```json
+{
+ "agent_name": "my-agent",
+ "stage": "post",
+ "step": {
+ "type": "llm",
+ "name": "support-answer",
+ "input": "What is on file for me?",
+ "output": "Your refund goes to DE89 3704 0044 0532 0130 00.",
+ "context": {"jurisdiction": "DE"}
+ }
+}
+```
+
+### As a decorator argument
+
+`@control()` builds the step from the function's arguments, and it cannot set `step.context`. To use the decorator, pass the jurisdiction as a function argument and select it with `input.jurisdiction`.
+
+```python
+@control(step_name="support-answer", step_type="tool")
+async def support_answer(*, text: str, jurisdiction: str) -> str:
+ return text
+```
+
+
+ This works only for `tool` steps and custom step types, where `input` is the dict of all arguments. For an `llm` step, `input` is a single string, so the path `input.jurisdiction` does not resolve.
+
+
+
+The controls below use the `context` path. For the decorator path, make two changes in each control, or the controls never match and every request is allowed:
+
+- Set `"step_types"` to `["tool"]`.
+- Set the jurisdiction selector `path` to `"input.jurisdiction"`.
+
+
+## Author the Controls
+
+Each country rule is an `and` of two conditions:
+
+- The jurisdiction is in a list, checked with the `list` evaluator.
+- The rule matches, here a `regex` on the output.
+
+### Option 1: One control per country
+
+```python
+de_iban = {
+ "execution": "server",
+ "scope": {"step_types": ["llm"], "stages": ["post"]},
+ "condition": {
+ "and": [
+ {
+ "selector": {"path": "context.jurisdiction"},
+ "evaluator": {"name": "list", "config": {"values": ["DE"]}},
+ },
+ {
+ "selector": {"path": "output"},
+ "evaluator": {
+ "name": "regex",
+ "config": {"pattern": r"\bDE\d{2}(?: ?\d{4}){4} ?\d{2}\b"},
+ },
+ },
+ ]
+ },
+ "action": {"decision": "deny"},
+}
+```
+
+Write a similar control for each country, for example `US` with a Social Security number pattern.
+
+### Option 2: One composite control
+
+Put each country's `and` branch inside one `or`:
+
+```python
+countries = {
+ "execution": "server",
+ "scope": {"step_types": ["llm"], "stages": ["post"]},
+ "condition": {
+ "or": [
+ {"and": [jurisdiction_in(["DE"]), output_matches(DE_IBAN)]},
+ {"and": [jurisdiction_in(["US"]), output_matches(US_SSN)]},
+ ]
+ },
+ "action": {"decision": "deny"},
+}
+```
+
+`jurisdiction_in()` and `output_matches()` are small helpers in the example scripts. They return the leaf conditions shown in Option 1.
+
+### Which option to use
+
+| | One control per country | One composite control |
+|---|---|---|
+| Number of controls | One per country | One |
+| Match result | Names the country's control | Names the composite control |
+| Change one country | Edit or disable only that control | Edit the shared condition |
+
+## Baseline and Country Controls
+
+Most policies combine two kinds of rules:
+
+- **Baseline controls** apply to every country. They have no jurisdiction condition.
+- **Country controls** apply only to some countries. They include the jurisdiction condition.
+
+Keep them as separate controls. A baseline control is a plain control:
+
+```python
+baseline_email = {
+ "execution": "server",
+ "scope": {"step_types": ["llm"], "stages": ["post"]},
+ "condition": {
+ "selector": {"path": "output"},
+ "evaluator": {
+ "name": "regex",
+ "config": {"pattern": r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b"},
+ },
+ },
+ "action": {"decision": "deny"},
+}
+```
+
+The baseline and the country controls run together on each check. A baseline control also applies when the request has no jurisdiction.
+
+
+ Agent Control evaluates the controls in parallel and stops the remaining evaluations after the first `deny` match. If a baseline control and a country control both match, `result.matches` usually names only one of them.
+
+
+To tell the two kinds apart, use a naming convention or `tags`, for example `["baseline"]` and `["country", "DE"]`.
+
+## Scenarios
+
+Both authoring options give the same decisions. The example runs them side by side, with the baseline control on both:
+
+| Scenario | Expected | Matched by |
+|---|---|---|
+| DE + IBAN | Blocked | DE country rule |
+| US + IBAN (no US rule) | Allowed | - |
+| US + SSN | Blocked | US country rule |
+| DE + SSN (no DE rule) | Allowed | - |
+| US + clean text | Allowed | - |
+| DE + email | Blocked | Baseline |
+| No jurisdiction + IBAN | Allowed | - (country rules are skipped) |
+| No jurisdiction + email | Blocked | Baseline |
+
+## Missing Jurisdiction
+
+
+ If a request has no jurisdiction, no country rule matches. Only baseline controls apply, so the request is allowed unless a baseline control matches. Agent Control has no built-in default jurisdiction.
+
+
+If you need a strict default, add a control that matches when the jurisdiction is missing or not covered:
+
+```python
+strict_default = {
+ "execution": "server",
+ "scope": {"step_types": ["llm"], "stages": ["post"]},
+ "condition": {
+ "and": [
+ {"not": jurisdiction_in(["DE", "US"])},
+ {"or": [output_matches(DE_IBAN), output_matches(US_SSN)]},
+ ]
+ },
+ "action": {"decision": "deny"},
+}
+```
+
+Keep this control in sync with the country controls:
+
+- The `not` list must name every country that has a rule.
+- The `or` must repeat every country rule.
+
+For example, if you add an `FR` control but do not add its rule to the `or`, a request with no jurisdiction that matches the `FR` rule is allowed.
+
+## Files
+
+- `attribute_controls_context.py` - Sends the jurisdiction in `step.context` with `evaluate_controls()`
+- `attribute_controls_decorator.py` - Sends the jurisdiction as a `@control()` function argument
+
+## Source Code
+
+View the complete example with all scripts and setup instructions:
+
+[`Attribute-Based Controls Example`](https://github.com/agentcontrol/agent-control/tree/main/examples/attribute_controls)
diff --git a/examples/overview.mdx b/examples/overview.mdx
index b94cd91..9ceafba 100644
--- a/examples/overview.mdx
+++ b/examples/overview.mdx
@@ -72,5 +72,9 @@ Browse working examples that show how to add Agent Control to different framewor
Realistic enterprise scenario with PII protection, prompt-injection defense, and multiple tool types.
+
+
+ Apply rules only for some attribute values, such as the country, as one control per value or as one composite control.
+
diff --git a/sdk/python-sdk.mdx b/sdk/python-sdk.mdx
index 8b607a9..829f90f 100644
--- a/sdk/python-sdk.mdx
+++ b/sdk/python-sdk.mdx
@@ -182,7 +182,13 @@ When background refresh is enabled, the SDK refreshes control snapshots via `GET
#### `@control()`
```python
-def control(policy: str | None = None, step_name: str | None = None):
+def control(
+ policy: str | None = None,
+ step_name: str | None = None,
+ step_type: str | None = None,
+ *,
+ tools=None,
+):
```
Decorator to protect a function with server-defined controls.
@@ -191,6 +197,8 @@ Decorator to protect a function with server-defined controls.
- `policy`: Optional policy label for code readability when multiple policies exist
- `step_name`: Optional step name override for the registered step
+- `step_type`: Optional step type override, for example `"tool"`. If not set, tool-like functions are `tool` steps and other functions are `llm` steps
+- `tools`: Optional tool definitions for `llm` steps, as a static list or a provider called at each check
**Example:**