Skip to content

Stop a turn or record a violation

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.
// in guardrails/answer-length/index.ts
action: { 'on-violation': 'log-only' },

With halt, the command fails with the check's reason:

Terminal window
kindgi runs start --agent=my-pack.echo-agent --input='{"userMessage":"hi"}'
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:

Terminal window
kindgi runs get <run-id>
{
"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.

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

"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.

A guardrail whose check can't run at all (no check by that name is registered, its configuration is invalid, an llm-judge guardrail's judge can't be routed to a model, or the check throws: pack code that crashed, a pack service that couldn't be reached) never counts as passed:

  • 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:

    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.

Either way, the error is recorded in the run's provenance (the guardrail's node, with evaluated: false) and in the run journal's evaluate-guardrails step, under errors.

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.

A guardrail checks the turns of every agent that lists it. scope narrows that:

// in guardrails/answer-length/index.ts
scope: { when: 'always', agents: ['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.
  • when is always, runtime-only or ci-only. A ci-only guardrail doesn't run in an agent's turn.

From Kindgi 0.1.6, 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:

Terminal window
curl -s -H "authorization: Bearer $KINDGI_API_TOKEN" \
"$KINDGI_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"

The clients call it guardrails.outcomes. from and to are the window, at most 90 days; recent (0 to 50, 10 by default) is how many blocked turns to name. Reading it takes read on the guardrail and on the project.

  • 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 (A recorded violation).
  • blocked: the check found something, and its halt failed the turn (A halted turn).
  • errored: the check couldn't run: no such check, a bad configuration, or a judge that couldn't be routed (When the check can't run). A halt guardrail's error fails the turn too, and is counted here, not as blocked.

A turn the guardrail's scope didn't cover wasn't checked, so it isn't counted. Neither are replays and dry runs.

byAgentVersion has the same counts for each version of each agent (up to 100), the most checked first, so you can see 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.

That guardrail 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:

{
"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"
}

A guardrail's page (Guardrails, then the guardrail) has What it caught: the same counts, for the project you're in, over the last 24 hours, 7 days (the default), 30 days or 90 days. The window goes in the page's address (?period=30d), so a link opens it the same way.

What it caught, on acme.no-secret's page: "In the last 7 days it checked 6 answers: 4 passed, 2 blocked the answer (the turn failed).", the four windows with Last 7 days chosen, a row for each of acme.desk 2.0.0 and 2.1.0 (2 passed, 0 let through, 1 blocked, 0 couldn't run, 1 of 3 blocked), and the two runs it blocked, newest first.

  • 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 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 06:15 UTC, the earliest outcome kept for it here: outcomes go with their runs."), as recordedSince does.