> ## Documentation Index
> Fetch the complete documentation index at: https://yukta.infenia.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Write a rules supervisor

> Write rules that react to events, veto commands, hold commands for approval and answer input requests, save them as a supervisor and version them.

<dl />

**Use this when** the situations and the responses can be stated exactly. The `rules` engine decides with a JSON document and needs no code and no model. If the decision needs judgement, use an [agent supervisor](https://yukta.infenia.com/guides/supervise/write-an-agent-supervisor.md).

**Prerequisites:** [Assign a supervisor](https://yukta.infenia.com/guides/supervise/assign-a-supervisor.md) (you create and assign one the same way). Add `-H "X-API-Key: $YUKTA_API_KEY"` if your server has keys.

## Steps

**1. Start from a preset, or from nothing**

`GET /api/supervisor-engines/rules/presets` lists ready-made documents: `failure-guard` (pause a workflow when 3 nodes fail within 60 seconds), `approval-gate` (a stop waits for a person), `stall-watchdog` (pause a run silent for 10 minutes) and `too-many-running`. Fetch one, change it and save it as your own:

```bash theme={"system"}
curl -s localhost:9858/api/supervisor-engines/rules/presets
yukta-cli supervisors presets rules
```

**2. Write the rules**

A rule is a **reaction** (`when` an event matches, `then` send a command), a **veto** (`deny` a command) or a **hold** (`requireApproval`). Every `when` field you set must match; omit a field to match anything. Save as `ci-guard.json`:

```json ci-guard.json theme={"system"}
{
  "id": "ci-guard",
  "type": "rules",
  "description": "Pause on repeated failure, refuse KILL, hold production stops",
  "settings": {
    "allowedCommands": ["PauseWorkflowCommand"],
    "operatorAccess": ["StopWorkflowCommand"],
    "rules": [
      {
        "id": "pause-on-repeated-failure",
        "when": { "event": "NodeStatusChanged", "status": "FAILURE", "count": 3, "withinSeconds": 60 },
        "then": { "command": "PauseWorkflow", "reason": "3 nodes failed within 60 seconds" }
      },
      {
        "id": "no-kill",
        "deny": { "command": "StopWorkflow", "if": "mode == 'KILL'", "reason": "use DRAIN or CANCEL" }
      },
      {
        "id": "prod-stop",
        "requireApproval": {
          "command": "StopWorkflow",
          "if": "workflowId == 'prod-deploy'",
          "risk": "HIGH",
          "reason": "production stop"
        }
      }
    ]
  }
}
```

* **`when.event`** is `NodeStatusChanged`, `WorkflowStatusChanged`, `ControlOutcome`, `LogLine`, `EventsMissed` or an input event (`InputRequested`, `InputEscalated`, `InputAnswered`, `InputExpired`, `InputCancelled`). `count` and `withinSeconds` (default 1 and 60) are counted per run.
* **`if`** is a condition over the event's fields (`status`, `module`, `nodeId`, `metadata['code']`, `risk`, `context['environment']`...). One that cannot be evaluated counts as not matching, so a faulty reaction never fires; a `deny` condition that cannot be evaluated **refuses**, so a faulty veto never lets a command through.
* **`then.command`** is `PauseWorkflow`, `ResumeWorkflow`, `StopWorkflow` (with `mode`), `PauseNode`, `ResumeNode`, `StopNode`, `Restart` or `RestartFromNode`. The supervisor must also list that command's name (`PauseWorkflowCommand`) in `allowedCommands`, or the command is dropped and counted as `forbidden`.
* **`deny`** and **`requireApproval`** read the command's fields: `executionId`, `actor`, `nodeId`, `mode`, `reason`, `immediate`, and for holds `workflowId`. A deny beats a hold.
* **`operatorAccess`** names what a person may send through this owner. A vetoed or held command must be listed here, or a person's command is refused earlier with `409 CONTROLLED_BY_SUPERVISOR`.

**3. Save it and give it something to own**

Rules are checked when saved, so a bad condition, an unknown command or a typo is reported with the rule that is wrong:

```bash theme={"system"}
curl -s -X POST localhost:9858/api/supervisors/validate -H 'Content-Type: application/json' -d @ci-guard.json
curl -s -X POST localhost:9858/api/supervisors -H 'Content-Type: application/json' -d @ci-guard.json
curl -s -X PUT localhost:9858/api/supervisors/ci-guard/assignments \
  -H 'Content-Type: application/json' -d '[{"target":"workflow:steer/slow"}]'
```

**4. Answer or escalate input requests**

Instead of a `command`, `then` can hold `answer` or `escalate` for an `InputRequested` or `InputEscalated` event. The supervisor needs `canAnswer: true` and a `maxRisk` that reaches the request (`LOW` or `MEDIUM`; `HIGH` is people only). The answer must be one of the request's option ids.

```json theme={"system"}
{
  "id": "input-rules",
  "type": "rules",
  "settings": {
    "canAnswer": true,
    "maxRisk": "LOW",
    "allowedCommands": ["AnswerInputCommand", "EscalateInputCommand"],
    "rules": [
      {
        "id": "approve-low-risk-staging",
        "when": { "event": "InputRequested", "if": "kind == 'APPROVAL' and risk == 'LOW' and context['environment'] == 'staging'" },
        "then": { "answer": { "decision": "approve", "rationale": "policy 7: low risk changes to staging are pre-approved", "confidence": 1.0 } }
      },
      {
        "id": "escalate-everything-else",
        "when": { "event": "InputRequested", "if": "not (kind == 'APPROVAL' and risk == 'LOW' and context['environment'] == 'staging')" },
        "then": { "escalate": { "reason": "not covered by policy 7", "advice": "Check the change and its target environment before you answer." } }
      }
    ]
  }
}
```

This is `examples/16-supervisor-profiles/input-requests.json`. The rationale is recorded as `rule <id>: <rationale>`. The prompt and context were written by the asking step, often a model: compare them, but do not base an approval on their claims alone. See [Route input requests](https://yukta.infenia.com/guides/supervise/route-input-requests.md).

**5. Change the rules without touching the rest**

`PUT /api/supervisors/{id}/engine/rules` replaces only the rules document and keeps `allowedCommands`, `operatorAccess` and the other shared settings. Every save is a version (the newest 20); saving swaps the running rules in whole.

```bash theme={"system"}
curl -s localhost:9858/api/supervisors/ci-guard/engine/rules
curl -s -X PUT localhost:9858/api/supervisors/ci-guard/engine/rules \
  -H 'Content-Type: application/json' -d '{"schemaVersion":1,"rules":[{"id":"no-kill","deny":{"command":"StopWorkflow","if":"mode == '"'"'KILL'"'"'","reason":"use DRAIN or CANCEL"}}]}'
yukta-cli supervisors rules get ci-guard
yukta-cli supervisors versions ci-guard
yukta-cli supervisors rollback ci-guard --version 1
```

A document that names a shared setting is refused; change those with `PUT /api/supervisors/{id}`. MCP: `get_rules`, `save_rules`, `list_supervisor_versions`, `rollback_supervisor`.

**6. Try the rules on a past run first**

Before you swap rules into production, replay a draft over a finished run: [Replay a supervisor](https://yukta.infenia.com/guides/supervise/replay-a-supervisor.md).

## Verify it worked

Start `steer/slow` (a run that lasts 45 seconds) and send a refused stop:

```bash theme={"system"}
curl -s -X POST "localhost:9858/api/executions/$EXEC/stop?mode=KILL"
```

```json theme={"system"}
{ "status": 403, "message": "use DRAIN or CANCEL", "errors": [ { "field": "code", "message": "CONTROL_REJECTED" } ] }
```

The run keeps going. A `DRAIN` stop passes the veto and ends it. For a held command (here `prod-stop` on a workflow named `prod-deploy`) the answer is `202` with `{"status":"AWAITING_APPROVAL","requestId":"..."}` and an input request of risk `HIGH` that only a person can approve ([Ask for input](https://yukta.infenia.com/guides/ai/ask-for-input.md)). To see what the supervisor did, list its decisions:

```bash theme={"system"}
curl -s "localhost:9858/api/supervisors/decisions?supervisor=ci-guard"
```

Each entry has an outcome: `applied`, `rejected`, `dry-run`, `forbidden`, `not-owned`, `rate-limited`, `awaiting-approval` and so on. `GET /api/control/audit` shows the same commands with their senders.

## Variations

* **Timed rules.** `"when": {"every": "30s", "status": "RUNNING", "if": "read.RUNNING > 10"}` runs on a timer over the runs the supervisor owns (`read.<STATUS>` counts them). A timed rule acts on its own, so it needs a `reason` and its command in the supervisor's `initiative` list (and `maxInitiativePerMinute`, default 10). The `too-many-running` and `stall-watchdog` presets are timed.
* **Bound a retry loop.** `Restart` and `RestartFromNode` start a new run; its events carry `attempt` (0 for a run nobody restarted). `"if": "attempt < 2"` stops the chain. The count lives in memory and starts again after a restart.
* **Do not lose events.** Event delivery is best effort, but a gap is never silent: a rule on `EventsMissed` (`missed` holds the count) can pause or fail safe.
* **A rules file for the whole server.** `yukta.supervisor.rules.file` starts a rule supervisor from a file, but it owns nothing, so it reviews nothing and can never answer. Use a saved supervisor with assignments instead.

## Related

* [Rule files reference](https://yukta.infenia.com/reference/supervisors/rule-files.md) for every field, event and command.
* [Supervisor shell reference](https://yukta.infenia.com/reference/supervisors/shell.md) for `allowedCommands`, `initiative` and `operatorAccess`.
* [Supervisors](https://yukta.infenia.com/concepts/supervisors.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.