Skip to content

Write a guardrail

This guardrail keeps an agent's answers short: it fails a turn whose answer is longer than a limit.

guardrails/answer-length/index.ts
import { defineCheck } from '@kindgi/sdk/define';
import { z } from 'zod';
export const check = defineCheck({
id: 'my-pack.checks.answer-length',
kind: 'zero-llm',
configSchema: z.object({ maxChars: z.number().int().positive().default(500) }),
evaluate: async (config, trace) => {
const length = trace.output?.length ?? 0;
if (length > config.maxChars) {
return {
passed: false,
reason: `The answer is ${length} characters; the limit is ${config.maxChars}.`,
};
}
return { passed: true };
},
});
export default {
id: 'my-pack.answer-length',
name: 'Answer is short',
kind: 'zero-llm',
check,
config: { maxChars: 60 },
action: { 'on-violation': 'halt' },
severity: 'error',
};

The file has two parts:

  • The check, from defineCheck, exported as check. Its evaluate gets the guardrail's config and the turn's trace, and returns { passed, reason? }.
  • The declaration, the default export: the guardrail's id, the check it runs, the check's config, and the action on a failure.
  • The id is <pack-id>.<name>. Name the rule as what should hold: answer-length, response-not-empty.
  • kind: 'zero-llm': the check is your code, a function of the trace. It can't call a model.
  • The reason is what the violation reports. Say what was wrong. It shows wherever the failure does (the run's error, its page in the console), so describe what you blocked, never quote it: "it contains a card number", not the number.
  • The trace has the turn's final answer (output), its tool calls (toolCalls / tool_calls, each with the tool's name and arguments), their results, its model calls, the user's input and its cost. It also names the run's project (projectId / project_id) and, when the project has one, its org (orgId / org_id), set by the runtime, never from the input. See RunTrace in the Python reference.

An agent lists its guardrails:

// in agents/echo-agent/index.ts
guardrails: ['my-pack.response-not-empty' as GuardrailId, 'my-pack.answer-length' as GuardrailId],

The agent names the guardrail's id, not the check's.

Save. kindgi dev registers the guardrail with the rest of the pack. An agent that names a guardrail that isn't registered doesn't run:

Error [unresolved-guardrail]: Agent "my-pack.gated" references guardrails not in the registry: my-pack.no-such-guardrail

dev-echo's answer, its "isn't a real model" line and the tool's result, is longer than 60 characters, so the turn stops:

Terminal window
pnpm exec 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 command exits with status 1. The run's status is failed, and it has no answer. Stop a turn or record a violation shows what the run records, and how to let the turn finish instead.

A check that throws fails the turn whatever its action, with the exception's message (handler-throw: Check "…" evaluate() threw: …). Return passed: false for a rule that isn't met; throw only when the check itself can't run.

guardrails/answer-length/index.test.ts
import type { RunId, TenantId } from '@kindgi/sdk/types';
import { expect, test } from 'vitest';
import { check } from './index.js';
const trace = (output: string) => ({
runId: 'run-1' as RunId,
tenantId: 'test-tenant' as TenantId,
output,
toolCalls: [],
toolResults: [],
modelCalls: [],
mode: 'runtime' as const,
});
test('passes a short answer', async () => {
expect(await check.evaluate({ maxChars: 60 }, trace('Shipped.'), {})).toEqual({ passed: true });
});
test('fails a long answer, with a reason', async () => {
const result = await check.evaluate({ maxChars: 5 }, trace('Shipped yesterday.'), {});
expect(result.passed).toBe(false);
expect(result.reason).toBe('The answer is 18 characters; the limit is 5.');
});
Terminal window
pnpm exec kindgi test
✓ guardrails/answer-length/index.test.ts (2 tests) 2ms
…
Tests 7 passed (7)

A project's Guardrails page lists its guardrails, with a headline such as "5 guardrails; 1 isn't named by any agent here." Each row says:

  • how it checks: "A rule: no model call", with the check;
  • what happens when it fires: "The turn fails", or "It is recorded; nothing stops";
  • when it applies: "In runs and in CI", or "In runs only, not in CI";
  • how many agents name it: "1 agent", or "No agent".

The Guardrails page: "5 guardrails; 1 isn't named by any agent here.", with No secrets in answers, Says thanks, No refund promises, No shouting and No placeholder text, each with how it checks, when it fires, where it applies and how many agents name it.

A guardrail's page has:

  • What it checks: how, the check, its settings, when it fires, its severity and where it applies.
  • Agents it checks: each agent version that names it and can run here, marked "(latest)" or "(live)".
  • What it caught: its outcomes by agent version (Halt or record).

No secrets in answers' page: a rule, forbidden-substring (built in), patterns ["password"], the turn fails, critical, in runs and in CI; the agent version that names it, Guarded agent 1.0.0 (latest); and What it caught.

A flow step runs the agent version its flow version pins, so that version is checked too: "Drafts agent 1.0.0 (pinned by flow acme.drafting 1.0.0)".

No shouting's page, whose agent version is one a flow pins: Drafts agent 1.0.0 (pinned by flow acme.drafting 1.0.0).

For guardrail admins, Unregister. Its dialog names the agent versions whose turns then fail, and how they come back:

Unregister No secrets in answers?
Guarded agent 1.0.0 (latest) names it: its turns fail, saying it isn't registered, until it's registered again or a version that doesn't name it goes live.
There's no reinstate: registering it again brings it back.
  • When a flow pins one of those versions, it adds: "The flow version that pins it fails at that step too: …".
  • Once it's done, the dialog shows the guardrail's definition, to register it again.
  • Past 100 agents or flows, the page and the dialog say which weren't checked: "Only the first 100 agents were checked." (or the first 100 flows' steps).

The Unregister No secrets in answers? dialog: Guarded agent 1.0.0 (latest) names it, its turns fail until it's registered again or a version that doesn't name it goes live, and there's no reinstate.