Concepts

Policy engine

How Klent evaluates an action against your policies. Operators, effects, field resolution, ordering.

The policy engine is the core of the control surface. Every time you call evaluateAction, the engine:

  1. Loads the enabled policies for the project.
  2. Walks them in creation order (oldest first).
  3. For each policy, checks that all of its conditions match the action.
  4. Returns the first matching policy's effect, or allow if no policy matched.

Evaluation is a pure function of the action payload and the policy set — no randomness, no time-of-day logic. Two identical inputs always produce the same decision.

Anatomy of a policy

{
  "name": "Block high-value USD transfers",
  "enabled": true,
  "effect": "deny",
  "conditions": [
    { "field": "tool", "operator": "equals", "value": "transfer_funds" },
    { "field": "input.amount", "operator": "greater_than", "value": 10000 },
    { "field": "input.currency", "operator": "equals", "value": "USD" }
  ]
}

A policy is one rule with one effect. To express "OR" across two situations, create two policies with the same effect.

Field resolution

Conditions target fields via dot-paths into the action payload. The evaluation context exposes:

PathWhat it is
toolThe tool name string.
input.*Any key (at any depth) inside the action input.
metadata.*Any key inside the action's metadata (if you pass it).

Examples:

  • tool"send_email"
  • input.to"[email protected]"
  • input.amount50000
  • input.user.role"admin"
  • metadata.tenant"acme"

Missing fields resolve to undefined. A condition against a missing field never matches — it is treated as false, not as an error.

Operators

OperatorMatches when
equalsactual and expected are strictly === equal
not_equalsactual and expected are not strictly equal
greater_thanboth are numbers and actual > expected
less_thanboth are numbers and actual < expected
containsactual is a string containing expected; or actual is an array that includes expected
ends_withboth are strings and actual ends with expected

Numeric operators (greater_than, less_than) return false for non-numeric inputs rather than throwing. String operators return false if either side is not a string.

Conditions (AND)

All conditions in a single policy must match for the policy to apply. To match on different conditions, create separate policies.

Effects

deny

The action is blocked. evaluateAction returns:

{
  "decision": "deny",
  "matched_policy_id": "pol_…",
  "modifications": null,
  "reason": "Matched policy \"Block high-value USD transfers\""
}

And Klent appends an action_blocked event to the execution timeline automatically. Your code should treat a deny as terminal: surface the reason to the LLM as a tool error, and move on.

allow

The action is permitted explicitly. Useful for creating a ranked allowlist — put your allow policies at the top so denies further down never fire for them.

modify

The action is permitted with changes. The response includes a modifications array describing fields to overwrite:

{
  "decision": "modify",
  "matched_policy_id": "pol_…",
  "modifications": [{ "field": "input.cc", "value": "[email protected]" }],
  "reason": "Matched policy \"Always CC audit on outbound email\""
}

Apply the modifications to your action input before executing. The SDK does not apply them for you — the decision is yours, so you can log a warning, require user confirmation, or silently comply.

approve (human-in-the-loop)

The action is parked until a human resolves it from the dashboard. The engine returns:

{
  "decision": "approve",
  "matched_policy_id": "pol_…",
  "modifications": null,
  "redirect_to": null,
  "pending_action_id": "pact_…",
  "reason": "Matched policy \"Pause large transfers for review\""
}

Klent creates a row in pending_actions and emits a pending_approval event on the timeline. The SDK can either:

  • Return immediately with status: "pending" and let the agent decide what to do (retry later, narrate to the user, fall back to a safer tool). This is the default.
  • Block until resolved by passing approval.wait (TS) / approval_wait (Python). The SDK polls GET /v1/pending_actions/:id; when a reviewer approves in the dashboard, the tool runs (with any last-mile modifications the reviewer staged). When rejected, the SDK returns status: "denied" with the reviewer's free-text note as the reason.
// TS — synchronous wait, server-side long-poll
const res = await runTool(klent, {
  execution_id,
  tool: 'transfer_funds',
  input: { amount: 9000 },
  execute: doTransfer,
  approval: { wait: { timeoutMs: 600_000 } },
});
if (res.status === 'denied') return showRejection(res.reason);
if (res.status === 'pending') return queueForLater(res.pendingActionId);

The dashboard's Approvals queue surfaces every pending row across the project, with one-click Approve / Reject buttons. Resolved decisions land in the per-execution timeline as approval_resolved events for audit.

Multi-approver (two-person rule)

Set required_approvals on the policy to demand more than one distinct human approve before the action releases. A single rejection still terminates the action — there is no override.

- name: finance / hold-large-refunds
  effect: approve
  required_approvals: 2 # default 1
  conditions:
    - field: tool
      operator: equals
      value: stripe.refunds.create
    - field: input.amount
      operator: greater_than
      value: 50000

required_approvals accepts integers from 1 to 10. Each individual vote emits an approval_vote event on the timeline; the deciding vote ALSO emits approval_resolved so existing alert / audit consumers keep their terminal-event semantics. Idempotency keys prevent the same human from voting twice.

steer (redirect to a different tool)

The action is permitted, but the engine substitutes a different tool for it. Stronger than modify, which only changes input fields — steer changes the target tool entirely. The response carries a redirect_to envelope:

{
  "decision": "steer",
  "matched_policy_id": "pol_…",
  "modifications": null,
  "redirect_to": {
    "tool": "send_email_via_audited_relay",
    "input": { "to": "[email protected]", "audited": true }
  },
  "reason": "Matched policy \"Route prod email through audit relay\""
}

The SDK's runTool runs the redirect transparently. If you provide an executeSteered (tool, input) => … callback, that callback receives the new tool name and input. Otherwise the original execute callback is reused with the steered input — works when execute is a single dispatcher.

A steer match writes an action_steered event to the timeline before the substitute call runs, so the dashboard timeline shows the redirect even if the substitute later fails.

When to use it:

  • Sandbox redirection. Steer prod-pointed tools to dev or staging implementations in non-prod environments.
  • Vendor swap. Quietly route email through an audited relay, search through a cheaper provider, model calls through a smaller model.
  • Hot-patching agent behavior. Fix an agent's intent without redeploying it — steer the dangerous tool to a safer variant.

Ordering and the default

Policies are evaluated in creation order. The first matching policy wins and evaluation stops. If no policy matches, the default is allow.

Design implications:

  • Put allow-lists above deny-alls. A narrow allow for a trusted tool will short-circuit before a broad deny.
  • Denies at the bottom are catch-alls. Build your policy set as a funnel from specific-allow → specific-deny → broad-deny.

Disabled policies

A policy with enabled: false is never evaluated. Use this to:

  • Stage changes. Create a policy disabled, validate it in the dashboard, then flip it on.
  • Emergency-off without deletion. Turn a misbehaving policy off while keeping the audit history (the ID stays stable, just inert).

Shadow (dry-run) mode

Policies have an enforcement_mode of either enforce (the default) or shadow. Shadow policies are evaluated normally — they match on the same conditions, in the same order — but their deny / modify effects are not applied. The action still runs.

When a shadow policy would have blocked or modified an action, Klent records that on the execution timeline with a shadow_decision field in the payload:

{
  "decision": "allow",
  "matched_policy_id": null,
  "reason": "No policy matched; default allow",
  "shadow_decision": "deny",
  "shadow_matched_policy_id": "pol_shadow_…",
  "shadow_reason": "Matched shadow policy \"Block high-value transfers\""
}

The dashboard surfaces these in the timeline with a shadow · would deny badge, so you can see what would have happened without the real path being affected.

When to use shadow mode

  • Rolling out a new policy. Run it shadow for a day, inspect the matches in the timeline, then flip to enforce.
  • Auditing. Encode organizational rules you don't yet want to enforce but want to count ("how often does the agent try X?"). The events are queryable without the real-world cost of denials.
  • Regression safety. Before editing a broad policy, add a shadow copy so you can diff behavior for a window.

Shadow policy ordering

A shadow match does not short-circuit evaluation. The engine continues past it looking for an enforce match. If none is found, the default allow applies — but the shadow is still reported. If an enforce policy matches later, the shadow is reported alongside the enforce decision.

Only the first shadow match is reported per action (the engine picks the earliest in creation order).

Caching

For MVP the policy set is re-fetched from Postgres on every evaluateAction. The query is a single indexed lookup on (project_id, enabled) — latency measured in sub-millisecond for typical projects (< 100 policies).

A per-project in-process cache is being reintroduced once cross-process invalidation lands (via Postgres LISTEN/NOTIFY).

Pre-filters: rate limits and loop guard

Two historical-event clauses can attach to any policy and gate its effect on prior tool-call history. They run before the standard condition match and produce the same decision shape as a normal policy fire — full coverage in Rate limits and loop guard.

  • rate_limit — fire when N matching tool calls have run in a sliding window (per-tool or per-(agent, tool)). Use for project-wide caps and per-agent fairness.
  • loop_guard — fire when the same tool has been called N times in a row inside the current execution. Use to break LLM retry loops without aborting the whole agent run.

What the engine does not do

  • It does not call out to LLMs or other services. Evaluation is local to the API process. Determinism and latency are non-negotiable.
  • It does not chain policies. Matching one policy does not "fall through" to the next. First match wins.