# Stop a turn or record a violation

> Choose what a failed guardrail does to an agent's turn, how severe it is, and which agents it covers.

This guide chooses what a failed guardrail does to an agent's turn: stop it, or let it finish and record the failure.

## Before you start

- **The answer-length guardrail** from [Write a guardrail](https://docs.kindgi.com/v0.1.10/guides/guardrails/write-a-guardrail.md), in the pack `my-pack`, with `kindgi dev` running and Kindgi 0.1's CLI.
- **`KINDGI_API_URL` and `KINDGI_API_TOKEN`** set in your shell, to count a guardrail's outcomes: your runtime's URL and its API token.

## Choose the action

A guardrail's action decides what a failed check does to the turn:

- **`halt`** stops it. The turn fails with `guardrail-violation`, and the run has no answer.
- **`log-only`** lets it finish. The answer goes out, and the failure is recorded on the turn as a violation.

Ask your coding agent, for example: "When my answer length guardrail fails, don't stop the turn. Just record it." Kindgi's skills in your pack tell it how ([How it knows Kindgi](https://docs.kindgi.com/v0.1.10/start/coding-agents.md)). For reference, it sets the action like this:

- **TypeScript**

  ```ts excerpt
  // in guardrails/answer-length/index.ts
  action: { 'on-violation': 'log-only' },
  ```

- **Python**

  ```python excerpt
  # in guardrails/answer_length.py
  @guardrail(
      id="my-pack.answer-length",
      name="Answer is short",
      on_violation="log-only",
      severity="error",
      config={"maxChars": 60},
  )
  ```

- **Java**

  ```java excerpt
  // in src/main/java/mypack/guardrails/AnswerLength.java
        .onViolation("log-only")
  ```

- **Scala**

  ```scala excerpt
  // in src/main/scala/mypack/guardrails/AnswerLength.scala
      .onViolation("log-only")
  ```

## A halted turn

With `halt`, starting a run fails with the check's reason:

- **TypeScript**

  ```sh
  pnpm exec kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}'
  ```

- **Python**

  ```sh
  uv run kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}'
  ```

- **Java**

  ```sh
  ./kindgiw runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}'
  ```

- **Scala**

  ```sh
  ./kindgiw runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}'
  ```

`--agent` is the agent the guardrail is on, and `--input` its input.

```text
Error [guardrail-violation]: Turn blocked by guardrail 'my-pack.answer-length': The answer is 243 characters; the limit is 60.
```

The run is `failed`, and its `failureMessage` carries the violation. Get the run:

- **TypeScript**

  ```sh
  pnpm exec kindgi runs get <run-id>
  ```

- **Python**

  ```sh
  uv run kindgi runs get <run-id>
  ```

- **Java**

  ```sh
  ./kindgiw runs get <run-id>
  ```

- **Scala**

  ```sh
  ./kindgiw runs get <run-id>
  ```

`<run-id>` is the failed run's id. `kindgi runs list` shows it first.

```json excerpt
{
  "id": "5202d750-e510-483f-991a-db34c6cba950",
  …
  "flowId": "agent.turn",
  "status": "failed",
  …
  "failureMessage": "{\"__agent_turn_failure__\":true,\"error\":{\"code\":\"guardrail-violation\",\"message\":\"Turn blocked by guardrail 'my-pack.answer-length': The answer is 243 characters; the limit is 60.\",\"violations\":[{\"guardrailId\":\"my-pack.answer-length\",\"result\":{\"passed\":false,\"reason\":\"The answer is 243 characters; the limit is 60.\"},\"action\":\"halt\",\"severity\":\"error\",\"at\":\"2026-10-03T20:10:37.910Z\"}],\"evaluationErrors\":[]}}"
}
```

In the console, the run's page keeps the answer the model wrote in view, muted, under **Blocked by a guardrail, not sent: the answer the model wrote**, with the guardrail under **Which guardrail**. A turn that failed for another reason after its answer says **Not sent: the turn failed after the model wrote this answer**.

Use `halt` for a rule whose failure makes the answer unusable or unsafe to show.

## A recorded violation

With `log-only`, the turn completes, and its output lists the violation:

```json excerpt
  "status": "completed",
  …
  "output": {
    …
    "response": {
      "role": "agent",
      "actor": "my-pack.echo-agent",
      "content": "⚠ dev-echo isn't a real model: it only repeats what it's given. Add an LLM provider key (Anthropic, OpenAI, Gemini, Groq, OpenRouter…) to get real answers.\n\nTool responded: {\"echo\":\"hi\",\"echoedAt\":\"2026-10-07T13:20:06.619Z\",\"characterCount\":2}",
      …
    },
    …
    "violations": [
      {
        "at": "2026-10-03T20:10:57.958Z",
        "action": "log-only",
        "result": {
          "passed": false,
          "reason": "The answer is 243 characters; the limit is 60."
        },
        "severity": "error",
        "guardrailId": "my-pack.answer-length"
      }
    ],
```

Use `log-only` while you find out how often a new rule fails, or for a rule your app acts on itself: read `violations` from the turn's result.

> **Note: Other actions**
>
> The guardrail schema also accepts `retry`, `escalate` and `compensate`. In 0.1 a turn records them as violations, like `log-only`, and doesn't act on them: there's no second attempt, escalation or compensating tool call.

## When the check can't run

A guardrail whose check can't run at all never counts as passed. A check can't run when:

- no check by that name is registered;
- its configuration is invalid;
- an `llm-judge` guardrail's judge can't be routed to a model;
- with a pack service for each deployment, the check's pack isn't deployed into the run's project;
- the check throws, because pack code crashed or a pack service couldn't be reached.

What happens next depends on the action:

- With `halt`, the turn fails with `guardrail-violation`. Its `violations` is empty, `evaluationErrors` names the guardrail, and the message says why. Here a guardrail names a pack check that nothing deployed:

  ```text
  Error [guardrail-violation]: Turn blocked: guardrail 'acme.pack-only' couldn't run its check (unknown-check: Guardrail "acme.pack-only" references unregistered check "acme.checks.not-deployed")
  ```

- With any other action, the turn completes.

Where each deployment has its own pack service, as on Kindgi Cloud ([A pack service for each deployment](https://docs.kindgi.com/v0.1.10/deploy/self-hosted/serve-many-tenants.md#a-pack-service-for-each-deployment)), a guardrail's pack check runs from a pack deployed into the run's project, which is the guardrail's own, never from one in another project. So a guardrail that names a check deployed into another project can't run it, and the message ends: `A guardrail's pack check runs from a pack deployed into the run's project.`

Either way, the error is recorded in two places. The run's provenance has the guardrail's node, with `evaluated: false`. The run journal's `evaluate-guardrails` step has it under `errors`.

## Severity

`severity` is `info`, `warn`, `error` or `critical`. It's recorded with each violation, and doesn't change what the action does: a `log-only` guardrail can be `critical`.

## Which agents it covers

A guardrail checks the turns of every agent that lists it. `scope` narrows that. Ask your coding agent, for example: "Check answer length only on my echo agent." For reference, it sets the scope like this:

- **TypeScript**

  ```ts excerpt
  // in guardrails/answer-length/index.ts
  scope: { when: 'always', agents: ['my-pack.echo-agent'] },
  ```

- **Python**

  ```python excerpt
  # in guardrails/answer_length.py
  scope={"when": "always", "agents": ["my-pack.echo-agent"]},
  ```

- **Java**

  ```java excerpt
  // in src/main/java/mypack/guardrails/AnswerLength.java
        .set("scope", Map.of("when", "always", "agents", List.of("my-pack.echo-agent")))
  ```

- **Scala**

  ```scala excerpt
  // in src/main/scala/mypack/guardrails/AnswerLength.scala
      .set("scope", Map("when" -> "always", "agents" -> Seq("my-pack.echo-agent")))
  ```

* `agents` lists the agents the guardrail applies to. An agent that lists the guardrail but isn't in its scope isn't checked.
* `flows` lists flows, by id. The guardrail then checks only the turns a step of one of those flows starts, and replays of those turns: an agent's turn in another flow's step, or outside any flow, isn't checked. With `agents` as well, a turn must match both. To find a turn's flow, the runtime reads the turn's runs, and only for a turn with a guardrail that lists `flows`. If it can't read them, or the run a replay replays is gone, the turn fails with `persistence-error` rather than go unchecked.
* `when` is `always`, `runtime-only` or `ci-only`. A `ci-only` guardrail doesn't run in an agent's turn.

A turn the scope leaves out isn't counted in the guardrail's [outcomes](https://docs.kindgi.com/v0.1.10/guides/guardrails/halt-or-record.md#how-a-guardrail-is-doing) either.

## How a guardrail is doing

Every check a guardrail runs on an agent's turn is recorded with its outcome. One request counts them over a window, for a project. The console shows the same counts.

### From the API

Here `acme.no-secret` halts on `SECRET`, on an agent at versions 2.0.0 and 2.1.0. Each version answered two questions and was asked for the secret once. Count its outcomes:

```sh
curl -s -H @<(printf 'Authorization: Bearer %s\n' "$KINDGI_API_TOKEN") \
  "$KINDGI_API_URL/v1/guardrails/acme.no-secret/outcomes?projectId=a1e2b238-ae0b-4f03-b012-07875b9e5ee0&from=2026-10-11T06:02:19Z&to=2026-10-11T06:05:22Z"
```

- `acme.no-secret` is the guardrail's id.
- `projectId` is the project whose turns are counted.
- `from` and `to` are the window, at most 90 days apart.
- `recent`, left out here, is how many blocked turns to name: 0 to 50, 10 by default.

Reading it takes `read` on the guardrail and on the project. The clients call it `guardrails.outcomes`. It answers:

```json
{
  "guardrailId": "acme.no-secret",
  "from": "2026-10-11T06:02:19.000Z",
  "to": "2026-10-11T06:05:22.000Z",
  "counts": {
    "passed": 4,
    "violated": 0,
    "blocked": 2,
    "errored": 0
  },
  "byAgentVersion": [
    {
      "agentId": "acme.desk",
      "agentVersion": "2.0.0",
      "passed": 2,
      "violated": 0,
      "blocked": 1,
      "errored": 0
    },
    {
      "agentId": "acme.desk",
      "agentVersion": "2.1.0",
      "passed": 2,
      "violated": 0,
      "blocked": 1,
      "errored": 0
    }
  ],
  "recentBlocked": [
    {
      "runId": "4566d55d-6d49-43d2-be14-2b1419611f71",
      "at": "2026-10-11T06:04:20.202Z",
      "agentId": "acme.desk",
      "agentVersion": "2.1.0"
    },
    {
      "runId": "2e462cfd-7597-4c72-9fb7-1152141e46ee",
      "at": "2026-10-11T06:04:05.906Z",
      "agentId": "acme.desk",
      "agentVersion": "2.0.0"
    }
  ],
  "recordedSince": "2026-10-11T06:03:57.454Z"
}
```

### What each outcome means

- **`passed`:** the check ran and found nothing.
- **`violated`:** the check found something, and the answer went through: `log-only`, `noop`, or an action handed back. See [A recorded violation](https://docs.kindgi.com/v0.1.10/guides/guardrails/halt-or-record.md#a-recorded-violation).
- **`blocked`:** the check found something, and its `halt` failed the turn. See [A halted turn](https://docs.kindgi.com/v0.1.10/guides/guardrails/halt-or-record.md#a-halted-turn).
- **`errored`:** the check couldn't run, as [When the check can't run](https://docs.kindgi.com/v0.1.10/guides/guardrails/halt-or-record.md#when-the-check-cant-run) lists. A `halt` guardrail's error fails the turn too, and is counted here, not as `blocked`.

A turn the guardrail's [scope](https://docs.kindgi.com/v0.1.10/guides/guardrails/halt-or-record.md#which-agents-it-covers) didn't cover wasn't checked, so it isn't counted. Neither are replays and dry runs.

### By agent version

`byAgentVersion` has the same counts for each version of each agent, up to 100 versions, the most checked first. It shows whether a new version trips the guardrail more or less than the last. `recentBlocked` names the latest blocked turns, newest first, by run id and time only: a blocked answer stays behind its run's page.

Outcomes go with their run's retention. `recordedSince` is the earliest one kept for the guardrail in the project, so a window that starts before it holds fewer than you asked for. A guardrail that's no longer registered keeps its outcomes.

### In the console

Open **Guardrails**, then the guardrail. Its page has **What it caught**: the same counts, for the project you're in, over the last 24 hours, 7 days, 30 days or 90 days. 7 days is the default. The window goes in the page's address, as `?period=30d`. So do the table's sort and columns, so a link opens the page the same way.

![The page of No secrets in answers (acme.no-secrets), with What it caught: "In the last 7 days it checked 14 answers: 11 passed, 3 blocked the answer (the turn failed).", "Counted from 2026-10-11 12 UTC, …", the four windows with Last 7 days chosen, the Columns menu, a row for each of acme.guarded 1.0.0 (6 passed, 0 let through, 2 blocked, 0 couldn't run, "2 of 8 blocked (25%)") and 1.1.0 (5, 0, 1, 0, "1 of 6 blocked (16.7%)"), and the three runs it blocked, newest first.](https://docs.kindgi.com/v0.1.10/_astro/what-it-caught.DiUqfL62_LxQPR.webp)

- **The sentence** counts the answers it checked in the window, by outcome: "passed", "found something without stopping the turn" (`violated`), "blocked the answer (the turn failed)" and "couldn't run" (`errored`).
- **By agent version:** a row for each version, with **Let through** for `violated` and **Couldn't run** for `errored`, and the blocked share. The agent's name opens its page. The table sorts by a column's header, and **Columns** chooses which columns it shows.
- **The latest runs it blocked:** each time opens that run's page, where the blocked answer stays withheld.
- **When outcomes start later than the window**, it says from when it counted ("Counted from 2026-10-11 12:22 UTC, the earliest outcome kept for it here: outcomes go with their runs."), as `recordedSince` does.

## Next

- [Configure a guardrail](https://docs.kindgi.com/v0.1.10/guides/guardrails/configure-a-guardrail.md)
- [Use a built-in check](https://docs.kindgi.com/v0.1.10/guides/guardrails/use-a-built-in-check.md)
- [List runs](https://docs.kindgi.com/v0.1.10/guides/runs/list-runs.md)
