GUIDES
Policies
One agent, different behaviour for different users. A policy reads the user context at the start of a turn and either takes tools away or adds instructions.
A policy is an IF/THEN rule stored on a project. The IF is one test against the user context that arrived with the request. The THEN either removes named tools from what the model is offered, or appends text to the system prompt. Policies run on every turn, on every entry point, before the model is called.
The record
Every policy is the same seven fields. This is exactly what the API returns and what the engine reads.
{
"id": "p_3f9a21",
"name": "Viewers cannot issue refunds",
"description": "Read-only roles lose the write tools.",
"enabled": true,
"condition": { "field": "user.role", "operator": "eq", "value": "viewer" },
"action": { "type": "deny_tools", "tools": ["issue_refund", "cancel_order"] },
"trigger_count": 128
}| Field | Type | Description |
|---|---|---|
id | string | Assigned on create: the literal prefix p_ plus six hex characters. Not writable. |
namerequired | string | Shown in the dashboard. Has no effect on evaluation. |
description | string | Free text. Defaults to the empty string. |
enabled | boolean | Defaults to true. A disabled policy is skipped before its condition is read, so it costs nothing and never counts a trigger. |
conditionrequired | object | The IF. Must contain field; operator defaults to eq and value defaults to null. |
actionrequired | object | The THEN. Must contain type. The remaining keys depend on the type. |
trigger_count | integer | Incremented once per turn each time the condition is true, in memory and in the project_policies row. Not writable — the update route ignores id and trigger_count even if you send them. |
condition.field and action.type are read without a guard, and the create route accepts any object for either. A policy stored with no field raises on every turn of that project; one with no type raises on every turn its condition matches. Either way the caller receives the generic {"type":"error"} SSE event instead of a reply. Send both keys.When policies run
Policies are evaluated once per turn, immediately before the first model call, and the result holds for the whole turn — including a multi-step loop where the agent calls several tools in a row. There is no re-evaluation between steps.
The order of operations in a turn:
- The project's tools are narrowed to the ones marked enabled.
- Policies run over that list, in creation order.
- A scheduled (unattended) run then drops any tool marked
requires_confirmationthat the task did not pre-authorize. - Reserved platform tools are appended:
_era_saveoutside an active collection, and the scheduling tools on a live turn of a scheduling-enabled project. - The system prompt is assembled in this order: your prompt template, the agent core, the clock block, user memory, any active collection block, knowledge base excerpts, and policy-injected context last.
schedule_task or _era_save in a deny_tools action has no effect.The same pipeline serves every entry point. POST /v1/chat, the embedded widget, the dashboard Playground and a scheduled run all go through it, so a policy you write once applies to all four. Trigger counts include Playground turns.
Conditions
A condition is one field, one operator, one value. There is no AND, no OR, and no nesting. To express two tests, write two policies — every matching policy fires.
| Field | Type | Description |
|---|---|---|
fieldrequired | string | A dot path into the user context, or the literal string always. The path has user. removed before it is walked, so user.role and role read the same field, and user.data.plan reads data.plan. |
operator | string | One of eq, neq, gt, lt, contains. Nothing else exists — there is no gte, lte, in, or negated contains. |
value | string | number | The right-hand side. Compared as described below. |
field: "always" short-circuits to true before the operator and value are looked at, so both are ignored. That is how a policy is made to apply to every user.
What each operator actually does
| Field | Type | Description |
|---|---|---|
eq | string compare | Both sides are stringified, then compared exactly. 5 and "5" match; "Gold" and "gold" do not. |
neq | string compare | The inverse of eq, on the same stringified values. |
gt | numeric | The field is read as a number, with a missing, empty or false value read as 0. If either side will not parse as a number the comparison is false. |
lt | numeric | Same rules as gt, in the other direction. |
contains | substring | True when the policy value appears anywhere inside the field value, compared in lower case. A missing field is treated as the empty string, so it never matches. |
eq and neq it stringifies to the literal text None, so user.data.plan neq pro is true for a user who has no plan field at all. Under gt and lt it reads as 0, and under contains as the empty string. Write conditions that fail closed: test for the value that should grant something, not for the value that should deny it.true in user.data stringifies to True, with a capital T. A condition of user.data.is_staff eq true therefore never matches; the value has to be written True. Sending the flag as the string "yes" or "no" from your backend avoids the trap entirely.Objects and arrays are usable, but only through contains, and only as text. A field holding ["trial", "eu"] is stringified to ['trial', 'eu'] before the match runs, so contains "trial" is true. This is a plain substring test with no word boundaries: that same condition is also true for a tag named trial_expired. The dashboard reflects this — a data field declared in your user schema as an object or an array is offered in the policy field picker but nowhere else.
contains, so "deny unless the user has scope X" cannot be written directly. Model it as a positive test on a field you control from your backend, for example user.data.billing_access eq off.Actions
Three action types exist. Two of them do something.
deny_tools
{ "type": "deny_tools", "tools": ["issue_refund", "cancel_order"] }Each name in tools is removed from the tool list handed to the model for this turn. Names match the tool's name, not its display name. A name that is not in the list is ignored without an error, so a typo fails silently and a deleted tool leaves the policy harmless. Tools imported from an MCP server are ordinary project tools and can be denied the same way.
deny_tools is a steering mechanism, not an authorization boundary. Enforce anything that genuinely must not happen in the webhook the tool calls, where you can check the user yourself.inject_context
{ "type": "inject_context", "context": "This account is on a trial. Do not promise custom work." }The string is appended to the end of the system prompt for this turn, after the knowledge base excerpts. It is instructions to the model, nothing more: it does not restrict anything, and the model can be argued out of it. Use it for tone, emphasis and proactive nudges, and use deny_tools when something must be off the table.
enforce_isolation
{ "type": "enforce_isolation" }This action does nothing in the policy engine. Tenant isolation is enforced at the query layer, by an org_id carried on every table, whether or not such a policy exists. The type is kept so seeded projects can display it and count its triggers. The dashboard marks it System, does not offer it when you create a policy, and hides its toggle and delete controls.
Evaluation order
Policies are read in creation order and all of them are evaluated. There is no first-match-wins and no way to stop the chain. Every enabled policy whose condition is true fires and increments its own trigger count.
Because no action adds a tool back, denials only accumulate: two policies denying overlapping sets produce the union, and their relative order does not change the outcome. Injected context does depend on order — the strings are appended in policy order, so an older policy's text appears above a newer one's in the prompt.
type is skipped, but its trigger count still goes up. A rising count on a policy that seems to do nothing usually means a misspelled action type.Variables inside a policy
action.context is rendered through the same resolver as your system prompt template, in prompt scope. These resolve:
{{user.id}},{{user.name}},{{user.email}},{{user.role}},{{user.company}}{{platform.name}}— the project name{{user.data.<field>}}for anydatafield holding a scalar
Whitespace inside the braces is tolerated, so {{ user.name }} behaves like {{user.name}}. Anything the resolver does not recognize is left alone: the literal braces travel into the prompt and the model reads them as text. That applies to a typo, to a data field holding an object or array, and to the two tool-scope variables — {{user.auth_token}} and {{user.creds.<name>}} are secrets, are never substituted into a prompt, and must not be put in an injected context.
condition.value is not templated. A value of {{user.role}} is compared as that literal 13-character string, so it will match nothing. A condition compares one user field against a constant; comparing two user fields is not supported.Two worked examples
Take write tools away from a read-only role
The project has issue_refund and cancel_order enabled for support agents. Viewers should be able to ask about an order and nothing else.
{
"name": "Viewers are read-only",
"description": "The viewer role loses both write tools.",
"enabled": true,
"condition": { "field": "user.role", "operator": "eq", "value": "viewer" },
"action": { "type": "deny_tools", "tools": ["issue_refund", "cancel_order"] }
}A turn arriving with {"id": "u_88", "role": "viewer"} is offered neither tool. The model answers from the knowledge base and its own reasoning, and when the user asks for a refund it explains that it cannot do that rather than calling anything. A turn with "role": "agent" is unaffected, because eq is an exact match.
Warn the agent about an account at its credit limit
Your backend already puts credit_utilization_pct into user.data. Above 85 you want the agent to raise it without being asked.
{
"name": "High credit utilization",
"description": "Nudge the agent to mention the credit position.",
"enabled": true,
"condition": { "field": "user.data.credit_utilization_pct", "operator": "gt", "value": 85 },
"action": {
"type": "inject_context",
"context": "{{user.company}} is over 85% of its credit limit. Raise this early in the conversation and offer to help arrange a payment."
}
}With a user context of {"id": "u_12", "company": "Acme Foods", "data": {"credit_utilization_pct": 92}} the condition reads 92 against 85 and fires, and the last line of the system prompt for that turn becomes Acme Foods is over 85% of its credit limit…. A user whose credit_utilization_pct is missing entirely reads as 0, so the policy stays quiet — which is the behaviour you want here, and the reason to phrase numeric conditions as gt on the risky side rather than lt on the safe side.
id, name, email, role and company — there is no data. The first example still applies, because it tests user.role. The second never fires, because user.data.credit_utilization_pct is absent and reads as 0.Managing policies
Policies live in the Policies tab of your project, and behind four routes under /admin. Those routes authenticate with a dashboard session whose organization owns the project, not with a project API key; writes additionally require the admin or owner role.
/admin/projects/{project_id}/policies/admin/projects/{project_id}/policies/admin/projects/{project_id}/policies/{pol_id}/admin/projects/{project_id}/policies/{pol_id}GET returns {"policies": [...]} in creation order — the same order the engine evaluates them in. POST requires name, condition and action, and returns the created record with its new id. PUT is a partial update: fields you omit are left alone, and id and trigger_count are never written. DELETE returns {"success": true}, or 404 if the policy does not belong to that project.
What you can and cannot do
As an integrator you do not write policies from your application — you control what they see. Every field a condition can test comes out of the user_context you send with the turn: role, company, and everything under data. Declaring those fields in your user schema is what makes them appear in the dashboard field picker.
Things policies do not do:
- They are not reachable from
/v1. There is no public endpoint to read, create or trigger one, and a project API key will not open the admin routes. - They are not reported back on the wire. No SSE event says which policies fired; the evidence is the trigger count in the dashboard and the turn's behaviour.
- They cannot stop knowledge base retrieval. Excerpts are fetched outside the tool list, so no
deny_toolsaction affects them. Restrict knowledge with document audiences instead. - They cannot add a tool, raise a limit, or re-enable something another policy denied.
user_context, so a user can set "role": "admin" in the console and walk out of any policy aimed at them. Turn on signed identity before a policy is doing anything you would be unhappy to see bypassed.