Rules
Manage rules in the Rules Library -- hard constraints (must / must-not) and preferences (should) that agents inject into context (enforcementMode: inject) and/or block tool calls on (enforcementMode: gate). Rules can belong to one or more agents (linkedIds), are versioned, and ordered by hierarchy: system → org → user → agent.
All endpoints require authentication via X-API-Key header and the appropriate scope.
Rule Object
{
"_id": "rule_abc123",
"ownerId": "user_456",
"ownerName": "Alice Example",
"organizationId": "org_789",
"sharedWith": [],
"isPublic": false,
"source": "user",
"tags": ["sql", "production"],
"linkedIds": [],
"currentVersion": 1,
"description": "Never run destructive SQL on production.",
"content": "When the target database has env=prod, refuse DROP / TRUNCATE / DELETE without WHERE.",
"category": "must-not",
"severity": "critical",
"hierarchyScope": "org",
"triggers": {
"keywords": ["drop table", "truncate", "delete from"],
"toolName": ["sql.execute"],
"always": false
},
"enabled": true,
"enforcementMode": "both",
"violationCount": 0,
"scoreResult": {
"score": 91,
"grade": "A",
"rulesetVersion": "1.0.0",
"failedCriticalCount": 0,
"failedHighCount": 0
},
"lastAssessedAt": "2026-05-10T12:00:00Z",
"createdAt": "2026-04-01T09:00:00Z",
"updatedAt": "2026-05-10T12:00:00Z"
}
linkedIds lists the agents the rule belongs to. lastViolationAt and lastTriggeredAt appear once the rule has been violated or matched. scoreResult (its quality score and grade) and lastAssessedAt are kept current by every change to the rule; assess returns the full report behind them.
GET /api/v1/primitives/rules
List rules with filters and pagination.
Scope: rules:read
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
category | string | No | must / must-not / should |
severity | string | No | critical / high / medium / low |
hierarchyScope | string | No | system / org / user / agent |
enabled | boolean | No | Filter by enabled state |
tags | string | No | Comma-separated tag list; a rule must carry all of them |
linkedIds | string | No | Comma-separated agent IDs; a rule must belong to all of them |
q | string | No | Full-text search on description, content, and tags |
limit | integer | No | Max results (default 50, max 200) |
cursor | string | No | meta.nextCursor of the previous page; omit for the first page |
Response 200 OK
{
"data": [ /* Rule objects */ ],
"meta": {
"total": 12,
"limit": 50,
"nextCursor": null,
"requestId": "req_abc123"
}
}
POST /api/v1/primitives/rules
Create a new rule.
Scope: rules:write
Request Body
{
"description": "Never run destructive SQL on production.",
"content": "When the target database has env=prod, refuse DROP / TRUNCATE / DELETE without WHERE.",
"category": "must-not",
"severity": "critical",
"hierarchyScope": "org",
"triggers": {
"keywords": ["drop table", "truncate", "delete from"],
"toolName": ["sql.execute"]
},
"enabled": true,
"enforcementMode": "both",
"tags": ["sql", "production"],
"linkedIds": []
}
| Field | Type | Required | Description |
|---|---|---|---|
description | string | Yes | Trigger predicate / one-line summary |
content | string | Yes | Rule body (loaded into context on match) |
category | string | No | must / must-not / should (default: must-not) |
severity | string | No | critical / high / medium / low |
hierarchyScope | string | No | system / org / user / agent |
triggers | object | No | {keywords?, toolName?, embedding?, always?} |
enabled | boolean | No | Default true |
enforcementMode | string | No | inject / gate / both (Phase 1: inject only) |
tags | string[] | No | Tags |
source | string | No | Provenance label (default: user) |
linkedIds | string[] | No | Agent IDs the rule belongs to |
Response 201 Created
{
"data": { "ruleId": "rule_abc123" },
"meta": { "requestId": "req_abc123" }
}
GET /api/v1/primitives/rules/:id
Get a single rule by ID.
Scope: rules:read
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Rule ID |
Response 200 OK
Returns the full Rule object.
PATCH /api/v1/primitives/rules/:id
Update a rule. Content changes append a new version row.
Scope: rules:write
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Rule ID |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
description | string | No | One-line summary |
content | string | No | Rule body |
category | string | No | must / must-not / should |
severity | string | No | critical / high / medium / low |
hierarchyScope | string | No | system / org / user / agent |
triggers | object | No | Trigger predicate |
enabled | boolean | No | false turns the rule off (it is neither injected nor enforced), true back on |
enforcementMode | string | No | inject / gate / both |
tags | string[] | No | Tags |
linkedIds | string[] | No | Agent IDs the rule belongs to (replaces the list) |
changeNote | string | No | Free-form note attached to the new version |
Response 200 OK
The rule, as GET /primitives/rules/:id shows it.
DELETE /api/v1/primitives/rules/:id
Delete a rule and its version history. Irreversible.
Scope: rules:write
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Rule ID |
Response 204 No Content
POST /api/v1/primitives/rules/search
Return the enabled rules whose triggers match the given turn / tool, sorted by hierarchy priority (system → org → user → agent). This is the progressive-disclosure read used by agents at turn start.
Scope: rules:read
Request Body
{
"userTurn": "Drop the users table on prod.",
"toolName": "sql.execute"
}
| Field | Type | Required | Description |
|---|---|---|---|
userTurn | string | No | Current user turn (matched against triggers.keywords) |
toolName | string | No | Tool about to be called (matched against triggers.toolName) |
linkedIds | string[] | No | Agent IDs; only rules linked to any of them, or linked to no agent, are considered |
Response 200 OK
{
"data": {
"rules": [ /* Rule objects */ ],
"count": 2,
"contextBlock": "..."
},
"meta": { "requestId": "req_abc123" }
}
contextBlock is the same rules rendered as a markdown block ready to place in a system prompt.
POST /api/v1/primitives/rules/import
Import rules from one source: the rows GET /primitives/rules/export emits, or a GitHub repository holding one rule. Send exactly one of rules and githubUrl.
Scope: rules:write
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
rules | array | One of the two | Rule rows, as the export emits them |
githubUrl | string | One of the two | https://github.com/owner/repo, with RULE.md at the repository root (YAML frontmatter; category is required in it) |
Response with rules 200 OK
{
"data": { "imported": 11, "unchanged": 1, "skipped": 0, "errors": [] },
"meta": { "requestId": "req_abc123" }
}
Imported rules belong to the caller. imported counts new rules; unchanged counts rows the caller already has (same description and body), which are not created again; skipped counts refused rows. A row is reported in errors (with its index, from 0) and skipped when its category is missing or not must / must-not / should, or when a severity, hierarchyScope or enforcementMode it gives is not one of the allowed values. A row that leaves those out gets the defaults.
Response with githubUrl 201 Created: the new rule. 400 validation-error for an invalid URL or unparseable frontmatter; 404 not-found when the repository has no RULE.md at its root.
GET /api/v1/primitives/rules/violation-summary
Aggregate cross-rule violation stats -- totals, per-rule top-N, per-day, top offending tools -- windowed to the last N days.
Scope: rules:read
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
days | integer | No | Window size in days (1–365, default 30) |
topRules | integer | No | Top-N rules to surface (default 10) |
topTools | integer | No | Top-N tools to surface (default 10) |
Response 200 OK
{
"data": {
"windowStart": "2026-04-16T10:00:00Z",
"windowEnd": "2026-05-15T10:00:00Z",
"totalViolations": 142,
"byRule": [
{ "key": "No destructive SQL on prod.", "count": 38 }
],
"byDetectedBy": [
{ "key": "gate", "count": 120 },
{ "key": "llm", "count": 22 }
],
"byDay": [
{ "date": "2026-05-15", "count": 12 }
],
"topOffendingTools": [
{ "key": "sql.execute", "count": 64 }
]
},
"meta": { "requestId": "req_abc123" }
}
byRule is keyed by the rule's description, byDetectedBy by how each violation was detected (llm, gate, user-report), and topOffendingTools by tool name. byDay has an entry for every day in the window, including days with no violations.
GET /api/v1/primitives/rules/export
Bulk-export every rule the caller can read as JSON.
Scope: rules:read
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
enabled | boolean | No | Filter to enabled (or disabled) rules |
Response 200 OK
{
"data": {
"exportedAt": "2026-05-16T10:00:00Z",
"count": 12,
"rules": [ /* Rule objects */ ]
},
"meta": { "requestId": "req_abc123" }
}
POST /api/v1/primitives/rules/:id/assess
Run the Rules quality ruleset against a row and cache the score on it.
Scope: rules:read
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Rule ID |
Response 200 OK
{
"data": {
"rulesetVersion": "1.0.0",
"assessedAt": "2026-05-16T10:00:00Z",
"score": 91,
"grade": "A",
"weightAwarded": 50,
"weightTotal": 55,
"failedCriticalCount": 0,
"failedHighCount": 0,
"checks": [
{
"id": "rule-description-nonempty",
"title": "Description is non-empty",
"category": "description",
"severity": "critical",
"weight": 10,
"awarded": 10,
"status": "pass",
"passed": true
}
]
},
"meta": { "requestId": "req_abc123" }
}
Each check's status is pass, fail or skipped. A skipped check does not apply to this row (for example a gate-only check on an inject rule): it earns nothing and its weight is left out of weightTotal, so score is weightAwarded / weightTotal over the checks that apply.
POST /api/v1/primitives/rules/check
Pre-call gate (Phase 3 substrate). Returns { allowed, blockedBy, reason } for a proposed tool call or action. Pattern-based: blocks only must-not rules with enforcementMode in {gate, both} whose triggers match. Agents call this before executing tools.
Scope: rules:read
Request Body
{
"toolName": "sql.execute",
"description": "Drop the users table on prod",
"args": { "query": "DROP TABLE users" },
"userTurn": "delete everything"
}
| Field | Type | Required | Description |
|---|---|---|---|
toolName | string | No | Tool the agent is about to invoke |
description | string | No | Human description of what the action will do |
args | object | No | Tool input arguments |
userTurn | string | No | Current user turn (for keyword-only rules) |
Response 200 OK
{
"data": {
"allowed": false,
"blockedBy": [ /* Rule objects */ ],
"reason": "Action blocked by 1 rule:\n• No destructive SQL on prod."
},
"meta": { "requestId": "req_abc123" }
}
When nothing blocks the action, allowed is true, blockedBy is empty and reason is null.
POST /api/v1/primitives/rules/:id/violations
Record a violation event against a rule.
Scope: rules:write
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Rule ID |
Request Body
{
"attemptedAction": "DROP TABLE users on env=prod",
"detectedBy": "gate",
"agentId": "aBcD1234EfGh5678",
"threadId": "thr_789",
"runId": "run_321",
"evidence": { "query": "DROP TABLE users" }
}
| Field | Type | Required | Description |
|---|---|---|---|
attemptedAction | string | Yes | Description of what was attempted |
detectedBy | string | No | gate (a gate rule blocked a tool call), llm (default) or user-report |
agentId | string | No | The agent (its workflow id) that attempted the action |
threadId | string | No | Originating thread |
runId | string | No | Originating run |
evidence | object | No | Free-form evidence payload |
Response 201 Created
The violation, as the rule's violations list shows it.
{
"data": { "_id": "v1", "ruleId": "r1", "attemptedAction": "tool:memory_ingest", "detectedBy": "gate", "detectedAt": "2026-10-11T12:00:00.000Z", "agentId": "wf-1" },
"meta": { "requestId": "..." }
}
GET /api/v1/primitives/rules/:id/violations
List a rule's violations, newest first. The rule's Violations tab pages through the same list, showing the agent and a link to the conversation.
Scope: rules:read
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Rule ID |
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | integer | No | Rows per page (default 100, max 200) |
cursor | string | No | meta.nextCursor of the previous page; omit for the first page |
Response 200 OK
{
"data": [
{
"_id": "viol_abc123",
"ruleId": "rule_xyz",
"attemptedAction": "DROP TABLE users on env=prod",
"detectedBy": "gate",
"detectedAt": "2026-05-15T14:22:00Z",
"agentId": "aBcD1234EfGh5678",
"threadId": "thr_789",
"runId": "run_321",
"evidence": { "query": "DROP TABLE users" }
}
],
"meta": {
"total": 38,
"limit": 100,
"nextCursor": null,
"requestId": "req_abc123"
}
}
Sharing
Who can reach a rule is the platform's one sharing shape: its owner, its members
(role editor can use and change it, user can only use it) and its visibility
(public: every user can find and use it; in a multi-tenant deployment, every user of
its organization). Changing it stays with its owner and editors.
| Method | Path | Does | Scope |
|---|---|---|---|
| GET | /api/v1/primitives/rules/:id/permissions | Owner, members (userId, role) and visibility | rules:read |
| POST | /api/v1/primitives/rules/:id/permissions/members | Share with a user: { "userId", "role": "editor" | "user" } | rules:write |
| DELETE | /api/v1/primitives/rules/:ruleId/permissions/members/:userId | Stop sharing with a user | rules:write |
| PATCH | /api/v1/primitives/rules/:id/permissions | { "visibility": "public" | "private" } | rules:write |
Each, except a member's removal (204), answers the permissions as they are now:
{
"data": {
"resourceId": "<rule id>",
"owner": "<user id>",
"members": [{ "userId": "<user id>", "role": "user" }],
"visibility": "private"
},
"meta": { "requestId": "req_abc123" }
}
GET /api/v1/primitives/rules/:id/versions
List every version of a rule.
Scope: rules:read
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Rule ID |
Response 200 OK
{
"data": {
"versions": [
{
"versionNumber": 1,
"content": "Original rule body",
"changeNote": null,
"createdAt": "2026-04-01T09:00:00Z"
}
],
"count": 1
},
"meta": { "requestId": "req_abc123" }
}
GET /api/v1/primitives/rules/:ruleId/versions/:version
Fetch one version of a rule.
Scope: rules:read
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
ruleId | string | Yes | Rule ID |
version | integer | Yes | Version number (positive integer) |
Response 200 OK
{
"data": {
"versionNumber": 1,
"content": "Original rule body",
"changeNote": null,
"createdAt": "2026-04-01T09:00:00Z"
},
"meta": { "requestId": "req_abc123" }
}
POST /api/v1/primitives/rules/:ruleId/versions/:version/restore
Restore a rule to a prior version. Appends a new version row -- does not mutate history.
Scope: rules:write
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
ruleId | string | Yes | Rule ID |
version | integer | Yes | Version number to restore to |
Response 200 OK
The rule, as GET /primitives/rules/:id shows it: its content is the restored version's, saved as a new version.