# 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, send the answer back for a new one, or let it finish and record the failure.

## Before you start

- **The answer-length guardrail** from [Write a guardrail](https://docs.kindgi.com/v0.5.0/guides/guardrails/write-a-guardrail.md), in the pack `my-pack`, with `kindgi dev` running and Kindgi 0.2'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.
- **`retry`** sends the answer back. The model gets the check's reason and writes a new answer, up to a number of answers you set: see [Send the answer back](https://docs.kindgi.com/v0.5.0/guides/guardrails/halt-or-record.md#send-the-answer-back).
- **`log-only`** lets it finish. The answer goes out, and the failure is recorded on the turn as a violation.

That's at the final answer, where a guardrail runs by default. A guardrail that runs at a tool call or a model reply acts there instead: [At a tool call or a model reply](https://docs.kindgi.com/v0.5.0/guides/guardrails/halt-or-record.md#at-a-tool-call-or-a-model-reply).

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.5.0/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 `escalate` and `compensate`. In 0.1 a turn records them as violations, like `log-only`, and doesn't act on them: there's no escalation or compensating tool call. Both are for the final answer only: a guardrail with either one at a tool call or a model reply is refused when its pack is built or deployed, or when it's registered.

## Send the answer back

With `retry`, a failed check neither stops the turn nor lets the answer out. The answer goes back to the model with the check's reason, and the model writes a new one. `retry.maxAttempts` counts the answers the guardrail fails, the first one included:

- **TypeScript**

  ```ts excerpt
  // in guardrails/answer-length/index.ts
  action: { 'on-violation': 'retry', retry: { maxAttempts: 3 } },
  ```

- **Python**

  ```python excerpt
  # in guardrails/answer_length.py
  @guardrail(
      id="my-pack.answer-length",
      name="Answer is short",
      action={"on-violation": "retry", "retry": {"maxAttempts": 3}},
      severity="error",
      config={"maxChars": 60},
  )
  ```

- **Java**

  ```java excerpt
  // in src/main/java/mypack/guardrails/AnswerLength.java
        .action(Map.of("on-violation", "retry", "retry", Map.of("maxAttempts", 3)))
  ```

- **Scala**

  ```scala excerpt
  // in src/main/scala/mypack/guardrails/AnswerLength.scala
      .action(Map("on-violation" -> "retry", "retry" -> Map("maxAttempts" -> 3)))
  ```

* **`maxAttempts`** is 1 to 10. With 3, the guardrail sends a failed answer back up to twice, and the third answer it fails ends the turn. Left out, it's 2: one answer is sent back. With 1, nothing is sent back, and the first failure fails the turn. Each `retry` guardrail counts its own, so a turn with two of them can have more answers than either allows.

* **The model reads why.** After the failed answer, the turn adds a user message that names the guardrail and the check's reason:

  ```text
  [kindgi:guardrail-retry] ["my-pack.answer-length"]
  Your answer wasn't sent: it didn't pass this guardrail.
  - my-pack.answer-length: The answer is 243 characters; the limit is 60.
  Write a new answer that meets it.
  ```

  Neither the failed answer nor the note is stored in the conversation: only the answer that's sent is. Each answer's tool calls are stored, as always.

* **Out of answers,** the turn fails as a halted one does, with `guardrail-violation`. `details.attempts` says how many answers it had, and so does the message: "Turn blocked by guardrail 'my-pack.answer-length' after 3 answers: The answer is 243 characters; the limit is 60." Each answer is a model call, so the agent's `budget.maxSteps` bounds them too, and so does the turn's own limit of 32 model calls: when either runs out first, the turn fails the same way.

* **`halt` comes first.** With a `retry` guardrail at the final answer, the agent's `halt` and `retry` guardrails there judge each answer, and a failed `halt` fails the turn at once. The other guardrails at the final answer (`log-only` and the rest) judge only the answer that's sent.

* **A new answer can do what the first one could.** The model still has the turn's tools, and can call them again, side-effecting ones included. A call that needs an approval needs one again.

* **Each answer costs its checks.** A `halt` or `retry` judge guardrail's calls run once for each answer, so as often as the turn has answers. They don't count toward the agent's `budget.maxCostUsd`.

* **A restart costs one more check.** If the runtime restarts while a turn is finishing, the turn it picks up judges its last answer on its `halt` and `retry` guardrails again, and those checks are counted again. If a `retry` check fails this time, the turn fails as out of answers.

* **A dry run** skips the model call: its answer is a placeholder, and nothing is sent back. A `retry` guardrail that fails that answer fails the turn, as one out of attempts does.

## At a tool call or a model reply

A guardrail whose `at` is `'before-tool'`, `'after-tool'` or `'model-step'` judges a step of the turn before the step takes effect ([Where in the turn it runs](https://docs.kindgi.com/v0.5.0/guides/guardrails/configure-a-guardrail.md#where-in-the-turn-it-runs)). In a Python, Java or Scala pack, a guardrail runs at the final answer only. Its action applies to that step:

- **Before a tool call.** With `halt`, the turn fails with `guardrail-violation`, and the call doesn't run. With `retry`, the call is refused: it doesn't run, and the model gets the guardrail's reason as the call's result, so it can go on without the call or make another one.
- **After a tool call.** The call has run. The guardrail judges what it returned, or the error it threw, before the model sees it. With `halt`, the turn fails. With `retry`, the result is withheld: the reason is stored as the call's result in its place, so neither the model nor a later turn's history sees the result. In a replay, the call's recorded result is judged as a live one is.
- **On a model reply** that asks for tool calls. With `halt`, the turn fails before any of the calls runs. With `retry`, the reply is sent back: none of its calls runs, nothing of it is stored, and the model writes a new reply.
- **With `log-only`,** the step goes ahead, and the failure joins the turn's `violations`.
- **A dry run** skips the model call, so its reply asks for no tools and no guardrail runs at these points.

With `halt`, the message says where the turn stopped. For the refund-limit guardrail from [Check a tool call or a reply](https://docs.kindgi.com/v0.5.0/guides/guardrails/write-a-guardrail.md#check-a-tool-call-or-a-reply):

```text
Error [guardrail-violation]: Turn blocked by guardrail 'my-pack.refund-limit' before calling acme.orders.refund: The refund is 250; the limit is 100.
```

With `retry`, the model gets this as the refused call's result:

```text
[kindgi:guardrail-retry] ["my-pack.refund-limit"]
This call didn't run: it didn't pass this guardrail.
- my-pack.refund-limit: The refund is 250; the limit is 100.
Don't make this call again as it is. Go on without it, or make a call that meets it.
```

A withheld result's note says the call ran and its result is withheld, and ends "Go on without its result." A reply that's sent back gets a user message instead, which says the reply wasn't acted on and none of its tool calls ran, and asks for a new reply.

- **Attempts count across the turn, one for each reply.** `retry.maxAttempts` counts the model's replies the guardrail acted on, the first one included: a reply it sent back, or one with a call it refused or a result it withheld. Two calls of one reply that it refuses are one attempt, since the model hasn't seen either refusal yet, and two guardrails that act on one reply count it once each. When they run out, or the turn has no steps left, the turn fails with `guardrail-violation`, and `details.attempts` says how many replies there were: "Turn blocked by guardrail 'my-pack.refund-limit' before calling acme.orders.refund, after 2 replies: The refund is 250; the limit is 100." A tool's own result never counts as an attempt, whatever its text.
- **The record says where.** A failed turn's `details.point` names the point, and at a tool call `details.callId` names the call. The `guardrail.violated` and `guardrail.error` events carry both, and so does the guardrail's `guardrail-check` node in the run's provenance.
- **A resumed turn decides nothing twice.** Each check's outcome at a point is recorded with the step. A turn resumed after an approval, or a step run again, reuses it: no judge is asked or billed again, and no event is sent again.
- **The final answer** runs only the guardrails at the final answer. Its trace still holds every tool call the model asked for.

## 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. At the final answer that's `unknown-check`. At a tool call or a model reply, such a check counts as one that runs at the final answer only, so it fails as `check-point-unsupported`, and the message says 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;
- the guardrail runs at a point its check doesn't run at (`check-point-unsupported`, with the check's id and the point);
- at a tool call or a model reply, the check runs past its time limit (`check-timed-out`, with `limitMs`), as [How long its check may take](https://docs.kindgi.com/v0.5.0/guides/guardrails/configure-a-guardrail.md#how-long-its-check-may-take) describes.

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 at the final answer 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")
  ```

  At a tool call or a model reply, the message also says where:

  ```text
  Error [guardrail-violation]: Turn blocked before calling acme.orders.refund: guardrail 'my-pack.refund-limit' couldn't run its check (check-timed-out: Guardrail "my-pack.refund-limit"'s check "my-pack.checks.refund-limit" ran past 10000 ms: the turn stopped waiting for it and cancelled it)
  ```

- With `retry` at the final answer, the error doesn't send the answer back: the turn goes on as if the check had passed.

- With `retry` at a tool call or a model reply, the error counts as a failed check: the call is refused, the result withheld or the reply sent back, with the reason `its check couldn't run (<code>): <message>`. So with `halt` or `retry`, a check that can't run never lets a call through.

- 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.5.0/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, at the final answer, 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`. At a tool call or a model reply, the error is in the `guardrail.error` event and the guardrail's `guardrail-check` node in the provenance, with the point and, at a tool call, the call.

In a turn with a `retry` guardrail at the final answer, the `halt` and `retry` guardrails there check each answer in the agent loop's `budget-check` step. A `halt` error there fails that step: provenance has the guardrail's node, as above, and the journal has the error on that step. An error on an answer that's sent back is in the guardrail's [outcomes](https://docs.kindgi.com/v0.5.0/guides/guardrails/halt-or-record.md#how-a-guardrail-is-doing), as `errored`, but not in provenance or under `errors`: those show the answer that's sent.

## 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.
* `tools` lists tools, by id, for a guardrail that runs before or after a tool call, as [Where in the turn it runs](https://docs.kindgi.com/v0.5.0/guides/guardrails/configure-a-guardrail.md#where-in-the-turn-it-runs) describes.

A turn the scope leaves out isn't counted in the guardrail's [outcomes](https://docs.kindgi.com/v0.5.0/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 answer is recorded with its outcome: once a turn, or, for a `halt` or `retry` guardrail, once an answer when the turn has a `retry` guardrail ([Send the answer back](https://docs.kindgi.com/v0.5.0/guides/guardrails/halt-or-record.md#send-the-answer-back)). 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.5.0/guides/guardrails/halt-or-record.md#a-recorded-violation). A `retry` guardrail that sent the answer back counts here too.
- **`blocked`:** the check found something, and its `halt` failed the turn, or a `retry` guardrail's last answer did. See [A halted turn](https://docs.kindgi.com/v0.5.0/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.5.0/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`.

The counts are of answers judged, not of turns. Take a turn whose first answer a `retry` guardrail sent back and whose second passed: it counts one `violated` and one `passed` for that guardrail, and two checks for each of the agent's `halt` guardrails. A blocked turn is named once in `recentBlocked`.

A turn the guardrail's [scope](https://docs.kindgi.com/v0.5.0/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, with What it caught: "In the last 7 days it checked 14 answers: 11 passed, 3 blocked the answer (the turn failed).", "Counted from 3 Nov 2026, 12 PM EST, …", the four periods with Last 7 days chosen, the Columns menu, a row for each of Guarded agent 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.5.0/_astro/what-it-caught.Pl_Rbpxq_lc5HR.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 25 Oct 2026, 1:45 AM EDT, the earliest outcome kept for it here: outcomes go with their runs."), as `recordedSince` does. Its times are on your own clock, or UTC ([The menu under your name](https://docs.kindgi.com/v0.5.0/guides/sso/sign-in.md#the-menu-under-your-name)).

## Next

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