Custom Policies
When the builtin policies don't cover your use case, you have two options:
cel_policy— write a CEL expression directly in your config. No code to deploy. Start here.- Python policy — a full Python function registered on the server. Use this only when your logic requires state, external calls, or anything else CEL's non-Turing-complete expressions cannot express.
Using cel_policy
cel_policy is a builtin policy that evaluates a
CEL expression against every event. CEL is a safe, sandboxed expression
language — no loops, no I/O, no side effects — which makes it the right default for most
custom rules.
The expression receives an event object with type and data fields (the same
PolicyEvent every policy sees). Return true to deny the
action.
Examples
Deny a specific tool:
policies:
no_dangerous_tool:
type: function
handler: omnigent.policies.builtins.safety.cel_policy
factory_params:
expression: "event.type == 'tool_call' && event.data.name == 'dangerous_tool'"
reason: "dangerous_tool is not allowed in this session."Block writes outside a specific directory:
policies:
confine_writes:
type: function
handler: omnigent.policies.builtins.safety.cel_policy
factory_params:
expression: >
event.type == 'tool_call' &&
event.data.name in ['sys_os_write', 'sys_os_edit'] &&
!event.data.arguments.path.startsWith('/home/user/project/')
reason: "Writes are only allowed inside /home/user/project/."Track calls with state_updates:
Return a state_updates list to accumulate counters or flags in session state. The engine
applies the mutations after the policy fires, so later events can read the updated values
via event.session_state.
policies:
track_tool_calls:
type: function
handler: omnigent.policies.builtins.safety.cel_policy
factory_params:
expression: >
event.type == 'tool_call'
? {"result": "ALLOW",
"state_updates": [
{"key": "tool_count", "action": "increment", "value": 1},
{"key": "last_tool", "action": "set", "value": event.data.name}
]}
: {"result": "ALLOW"}State persists across the whole session. A companion policy can read
event.session_state.tool_count to gate on the accumulated total.
Use cel_policy whenever your rule can be expressed as a boolean over the event fields. You
get the same policy lifecycle (ALLOW / ASK / DENY) without writing or deploying any code.
Python policies
Use a Python policy when cel_policy isn't enough — for example, when your rule needs
accumulated state across calls, a database lookup, or control flow that CEL cannot express.
1. Write a policy function
A policy is a Python function that receives an event and returns ALLOW,
ASK, DENY, or None (no opinion):
from omnigent.policies.schema import PolicyEvent, PolicyResponse
def my_policy(event: PolicyEvent) -> PolicyResponse | None:
if event["type"] != "tool_call":
return None
if event["data"]["name"] == "dangerous_tool":
return {"result": "DENY", "reason": "Blocked."}
return {"result": "ALLOW"}If your policy needs parameters, use the factory pattern. The function takes config and returns the evaluator:
def block_domains(blocked_domains: list[str]) -> callable:
blocked = frozenset(d.lower() for d in blocked_domains)
def evaluate(event: PolicyEvent) -> PolicyResponse | None:
if event["type"] != "tool_call":
return None
url = event["data"]["arguments"].get("url", "")
for domain in blocked:
if domain in url.lower():
return {"result": "DENY", "reason": f"Domain {domain} blocked."}
return {"result": "ALLOW"}
return evaluateNote: Omnigent imports policy functions by their dotted path, and it doesn't add the workspace or current directory to the import path. Install the package that contains your policy in both the Omnigent CLI environment and the server/runtime environment. When you launch an agent from the CLI, Omnigent checks its function-policy paths first and fails with an error if one can't be imported. That check doesn't import modules from the workspace directory either.
2. Register on the server
To make your policy discoverable by your Omnigent and visible in the UI, do two things:
Export a POLICY_REGISTRY from your module:
# myorg/policies.py
POLICY_REGISTRY = [
{
"handler": "myorg.policies.block_domains",
"kind": "factory",
"name": "Block Domains",
"description": "Block web access to specific domains.",
"params_schema": {
"type": "object",
"properties": {
"blocked_domains": {
"type": "array",
"items": {"type": "string"},
"description": "Domains to block"
}
},
"required": ["blocked_domains"]
}
}
]Add the module to your server config:
# config.yaml
policy_modules:
- myorg.policiesThen start the server with that config:
omnigent server -c config.yamlThe -c flag is short for --config.
Once registered, your custom policies appear alongside the builtins. Your Omnigent can select them when you ask it to add a policy in chat, and they show up in the UI settings panel.
Agent bundles uploaded over HTTP can only reference registered handlers. Starting
in 0.19.0 this applies on local single-user servers too. A bundle whose
type: function policy names an unregistered handler is rejected with HTTP 400
before the handler is imported. See
Uploaded agents.
3. Use it
Once registered, your custom policy works the same as any builtin. See Adding a policy for all the ways to apply it (chat, Omnigent YAML, or server config).
Reference: PolicyEvent
Every policy receives a PolicyEvent dict. Use the type field to
filter which events you care about:
| Event type | When it fires | Key data fields |
|---|---|---|
request | User sends a message | user_content (typed text), attachments (list of {filename, content_type, text}) |
tool_call | Omnigent is about to call a tool | name (tool name), arguments (dict) |
tool_result | A tool has returned a result | result (tool output); event["request_data"] carries the original tool_call payload |
response | Omnigent is about to deliver an assistant message | The assistant message text |
Return None for event types you don't handle.
For request events, text/code attachments (e.g. a CSV) carry their decoded
content in text. Archives, Office documents, and databases (.zip, .docx,
.xlsx, .pptx, .db, .sqlite, .sqlite3) are listed with their
filename and content_type but an empty text, so a policy can act on their
names and types without their contents being scanned as text.
Every event also carries:
event["target"]— tool name ontool_call/tool_result;Noneotherwise.event["session_state"]— read-only dict of per-session key/value pairs accumulated by earlier policies. Mutate it viastate_updatesin the return value, not by modifying this dict.event["context"]— metadata about the session (see below).
EventContext
event["context"] is a dict with the following fields:
| Field | Type | Description |
|---|---|---|
actor.run_as | str | Authenticated user email, e.g. "alice@example.com". Empty string when unknown. |
actor.client_id | str | OAuth client ID. Empty string when unknown. |
usage.input_tokens | float | Cumulative input tokens consumed this session. |
usage.output_tokens | float | Cumulative output tokens consumed this session. |
usage.total_cost_usd | float | Cumulative LLM cost in USD. 0.0 until the first LLM call or when pricing is unavailable. |
model | str | None | Active model, e.g. "databricks-claude-sonnet-4-6". None when unknown. |
harness | str | None | Active harness, e.g. "codex-native". None on web / API paths. |
labels | dict | Read-only snapshot of the conversation's guardrail labels. |
Reference: PolicyResponse
A policy callable returns None (abstain) or a dict with the following fields:
| Field | Type | Required | Description |
|---|---|---|---|
result | str | Yes | "ALLOW", "DENY", or "ASK" (case-insensitive). |
reason | str | No | Human-readable explanation. Shown to the user on ASK; logged on DENY. Optional on ALLOW. |
data | any | No | Replacement payload. When present on ALLOW, the enforcement site substitutes this value for the original event content — useful for returning a PII-redacted version of tool arguments. |
state_updates | list | No | Ordered list of session state mutations. Applied on ALLOW and DENY; withheld on ASK pending approval. See StateUpdateEntry. |
set_labels | dict[str, str] | No | Label key-value writes. Filtered through the policy's declared set_labels allowlist. |
Returning None is treated as abstain (equivalent to {"result": "ALLOW"}).
# minimal
{"result": "ALLOW"}
# full
{
"result": "DENY",
"reason": "Blocked.",
"data": <transformed-content>,
"state_updates": [
{"key": "call_count", "action": "increment", "value": 1},
],
"set_labels": {"integrity": "0"},
}Reference: StateUpdateEntry
Each entry in state_updates is a dict:
| Field | Type | Required | Description |
|---|---|---|---|
key | str | Yes | The state key to mutate, e.g. "call_count". |
action | str | Yes | One of "set", "increment", "delete", "append". |
value | any | Yes (except "delete") | The operand. Required for set, increment, and append; ignored for delete. |
The engine applies updates to event["session_state"] so subsequent policy evaluations see the new values.
State keys must be strings. Some keys are reserved for the runtime: every
key that begins with _policy_, plus subagent_cost_ask_approved_usd and
subagent_cost_over_budget_approved_usd. These hold approval checkpoints and
the counters behind builtin policies, and ordinary policies can't freely write
them:
- A policy may include a reserved key in
state_updatesonly on anASKresult. The update is applied only after a human approves the ask. - The builtin counters
_policy_tool_call_count,_policy_loop_recent_hashes, and_policy_spawn_bounds_dispatchescan be written only by their builtin policies (max_tool_calls_per_session,detect_loop, andspawn_bounds). - If a policy breaks either rule, or uses a non-string key, Omnigent treats the result as a policy error and the event is denied.
# overwrite
{"key": "status", "action": "set", "value": "escalated"}
# numeric delta (initializes to 0 if absent)
{"key": "call_count", "action": "increment", "value": 1}
# append to a list (initializes to [] if absent)
{"key": "seen_tools", "action": "append", "value": "sys_os_shell"}
# remove the key entirely
{"key": "temp_flag", "action": "delete"}