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.

json
{
  "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
}
FieldTypeDescription
idstringAssigned on create: the literal prefix p_ plus six hex characters. Not writable.
namerequiredstringShown in the dashboard. Has no effect on evaluation.
descriptionstringFree text. Defaults to the empty string.
enabledbooleanDefaults to true. A disabled policy is skipped before its condition is read, so it costs nothing and never counts a trigger.
conditionrequiredobjectThe IF. Must contain field; operator defaults to eq and value defaults to null.
actionrequiredobjectThe THEN. Must contain type. The remaining keys depend on the type.
trigger_countintegerIncremented 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:

  1. The project's tools are narrowed to the ones marked enabled.
  2. Policies run over that list, in creation order.
  3. A scheduled (unattended) run then drops any tool marked requires_confirmation that the task did not pre-authorize.
  4. Reserved platform tools are appended: _era_save outside an active collection, and the scheduling tools on a live turn of a scheduling-enabled project.
  5. 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.
Step 4 is why a policy cannot take away a reserved tool: those names are added after policies have already run. Listing 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.

FieldTypeDescription
fieldrequiredstringA 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.
operatorstringOne of eq, neq, gt, lt, contains. Nothing else exists — there is no gte, lte, in, or negated contains.
valuestring | numberThe 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

FieldTypeDescription
eqstring compareBoth sides are stringified, then compared exactly. 5 and "5" match; "Gold" and "gold" do not.
neqstring compareThe inverse of eq, on the same stringified values.
gtnumericThe 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.
ltnumericSame rules as gt, in the other direction.
containssubstringTrue 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.
A missing field does not behave the same way in every operator. Under 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.
A JSON 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.

There is no negated 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

json
{ "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.

Denial works by withholding the tool from the model, not by refusing the call. The executor looks a tool up by name in the full project list and does not re-check policies, so 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

json
{ "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

json
{ "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.

An action with an unrecognized 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 any data field 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.

json
{
  "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.

json
{
  "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.

Neither example works inside a scheduled run the same way. The scheduler rebuilds the user from a snapshot of 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.

GET/admin/projects/{project_id}/policies
POST/admin/projects/{project_id}/policies
PUT/admin/projects/{project_id}/policies/{pol_id}
DELETE/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.

The dashboard form creates and toggles policies but does not edit one in place. To change a condition or an action from the UI, delete the policy and create it again — which resets its trigger count, since the count belongs to the record.

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_tools action affects them. Restrict knowledge with document audiences instead.
  • They cannot add a tool, raise a limit, or re-enable something another policy denied.
A policy is only as trustworthy as the identity it reads. In the default open identity mode the browser writes 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.