What you get from this chapter: the complete rule vocabulary — 30 factories, what each one
prevents, one minimal example each — plus the four types they are written in and how to write your
own when nothing fits. Everything here is from looprun (≡ looprun/core).
Code source. §5 is generated from
GUARD_CATALOG(packages/core/src/guards/catalog.ts) byscripts/gen-guards-chapter.mjs; a parity test keeps that array in bijection with the factoriessrc/guards/actually exports, andpnpm docs:guards --checkruns in CI, so a row here cannot describe a kind that does not ship. Every §5 example is compiled: the same generator emitssnippets/04-guards-examples.generated.ts, which the snippets package typechecks against the publishedlooprunfacade. The hand-written sections quotesnippets/04-guards.tsandsnippets/scheduler/, typechecked the same way. Signature blocks are quoted from the library source and are not compiled here.
Chapter 03 left the scheduler with one hand-written rule and two obligations already met:
never double-book → argRequired + argFormat×2 + a custom clash gate (§8 of chapter 03)
never delete without ask → destructiveTools: ['cancelEvent']
⇒ AgentSpecBase installs confirmFirst + destructiveThrottle
This chapter is the rest of the vocabulary:
§1 the four types a guard is written in Guard · GuardCtx · ObservedCall · Dim
§2 binding one to a moment addGuard — the chapter 03 socket, in one line
§3 the ones you already have what AgentSpecBase installs before your code runs
§4 finding the right one symptom → kind, the confusable pairs, canonArgs
§5 THE CATALOG 30 factories, grouped by hook — generated
§6 writing your own custom, and the five rules a reviewer looks for
Read §1–§4 once; §5 is a reference you come back to.
Every factory in §5 returns one object of the same shape: a deterministic check and an LLM-facing prose (chapter 01 §3).
interface Guard {
kind: string; // the runtime name, e.g. 'confirmFirst'
dim: Dim; // which hooks it is legal on
check(ctx: GuardCtx): string | null | Promise<string | null>; // deny text, or null to allow
prose(): string; // the same rule, for the system prompt
meta?: { before?: string[]; requiredStrings?: string[] } & Record<string, unknown>;
}signature, from looprun
Three names, one rule, and they line up. The factory is what you call (confirmFirst(…));
it returns a Guard object; that object's kind is the runtime name ('confirmFirst'), and
it is what you read back in a guard id (base:confirmFirst) and in a recoveryEvents entry
(run:noDoubleBook:addEvent). The catalog below is indexed by factory name, so the name you call is
the name you will see in the audit trail — with one deliberate exception, custom, whose kind you
choose yourself (§6).
Two renderings of one rule, and the checker never reads the prose. A guard whose prose says something its check does not enforce is a rule the model is told about and nothing verifies — the failure this whole design exists to make impossible.
check returns the correction, not a log line: that string is what the model reads, so write it
as an instruction ("do not book it — name the clash and ask what to do"). Returning null allows.
interface GuardCtx {
args: Record<string, unknown>; // the proposed call's arguments (tool hooks)
tool?: string; // the proposed call's name (tool hooks)
world: AgentWorld; // your world — read it through a type, never bare
observed: ObservedCall[]; // every call THIS CONVERSATION, oldest first
turnIndex: number; // which turn is being adjudicated
reply?: string; // the reply text (onReply only)
result?: unknown; // the tool's result (postTool only)
producedThisTurn?: string[];
attachmentsThisTurn?: string[];
notes?: string[];
siblingCallsThisStep?: ObservedCall[]; // same-step calls still in flight — destructiveThrottle only
}signature, from looprun — abridged; the JSDoc on each field is worth reading
What is not there is the point: the user's text. No field carries the message. That is the magnet firewall — a rule that could be scoped by what the user said would be a rule the user can talk their way past. Guards key on arguments, world state and observed calls; nothing else.
world is typed as AgentWorld, whose index signature makes a typo compile (chapter 03 §7), so read
your accessors through a named type — §6 shows the pattern.
interface ObservedCall {
name: string;
args: Record<string, unknown>;
ok: boolean; // did the call succeed
turnIndex: number; // which turn it happened on
resultFlags?: { requiresConfirmation?: boolean }; // the two-step protocol's probe
tookEffect?: boolean; // did it MUTATE the world (vs a read)
}signature, from looprun
Three fields do the heavy lifting across the catalog. turnIndex is what makes "in an earlier
turn" expressible, which is the whole of confirmFirst. ok separates a call that happened from one
that succeeded. tookEffect separates a write that landed from a pure read or a refused write — it
is why noFalseFailureClaim does not veto an honest "I could not find it" on a read-only turn.
Two names in observed are runtime-owned rather than yours: replyToUser and askUser are pushed
in with ok: true, so a check that means "did the model do any work" must filter them out.
type Dim = 'spatial' | 'input' | 'run' | 'output' | 'behavior';signature, from looprun
A dim is a claim about which GuardCtx fields the check reads, and it is the only thing that
decides which hooks the guard may be installed on:
dim reads legal hooks
──────── ──────────────────────────── ──────────────────────────────
spatial ctx.tool / ctx.args onInput · preTool · postTool
input ctx.tool / ctx.args onInput · preTool · postTool
run ctx.tool / ctx.args + world onInput · preTool · postTool
output ctx.result postTool ← only hook that has it
behavior ctx.reply onReply ← only hook that has it
You never pass a dim for a catalog factory — each one carries its own. You pass one exactly once:
to custom (§6). Get it wrong and addGuard throws at construction, which is the point: a
behavior guard installed on preTool would read ctx.reply === undefined, never fire, and still
print its prose into the prompt — coverage that does not exist.
Chapter 03 §8 teaches the socket; this is the one-line reminder.
spec.addGuard(hook, target, guard, opts?) // hook: Hook, target: ToolTarget, guard: Guard
spec.addReplyCheck(guard, opts?) // ≡ addGuard('onReply', 'any', guard)
spec.addMutator(mutator, opts?) // for a ReplyMutator — §5's onReplyMutate rowsignatures — methods of AgentSpecBase
Every example in §5 is the third argument, and the section it is listed under is the hook it is
normally installed on — a convention, not the rule. The rule is §1's dim×hook matrix, which
addGuard enforces at construction.
Reach for addReplyCheck when you are binding a reply kind and 'any' is what you want anyway: it
is the same call with the two constant arguments removed, and it keeps a spec's reply block from
being a column of repeated 'onReply', 'any'.
Give every binding an id. It is what a GuardExecutionError names when a check throws, what the
eval output attributes a veto to, and what makes a spec diff readable.
AgentSpecBase's constructor installs the universal invariants before your code runs, and the
destructive-safety protocol iff you declared destructiveTools (chapter 03 §2):
ALWAYS (3) noDuplicateCall (preTool)
degenerationGuard (onReply)
emptyReply (onReply)
IFF destructiveTools (2) confirmFirst + destructiveThrottle, on exactly those tools
─────────────────────────── the five a spec like the scheduler's gets
IFF lexicon.falseFail… (1) noFalseFailureClaim (onReply) — the conditional sixth, and the
tutorial teaches no lexicon, so the scheduler does not get it
They have catalog rows in §5 because they are real kinds you must be able to read — not because you should call them. Re-adding one by hand renders the same rule twice in the prompt, from two sources that will drift.
You almost never shop the catalog top to bottom. You have a trace where the model did something, and you want the kind that makes that impossible. Read this column as "the model …":
| the model … | reach for | which is on |
|---|---|---|
| acts destructively without ever having asked | confirmFirst (auto-installed by destructiveTools) |
preTool |
| asks and acts in the same breath, or chains two destructive calls in one turn | noActAfterAskSameTurn · destructiveThrottle |
preTool |
| makes the same call again, hoping for a different answer | noDuplicateCall |
preTool |
| calls a legitimate tool too many times — sweeps, repeat contact | maxCalls |
preTool |
| runs a step before the one it depends on | requiresBefore |
preTool |
| acts while the world says it must not (closed account, no consent on record) | precondition · consentRequired |
preTool |
| does as it is told by text that came back INSIDE a tool result | noInstructionFromData |
preTool |
| summarises an empty or partial result as if it satisfied the request | resultInvariant |
postTool |
| says a tool's work is done when it is not | noFabricatedSuccess · destructiveClaimRequiresSuccess |
onReply |
| apologises for failing on a turn where the work went through | noFalseFailureClaim |
onReply |
| promises a handoff — billing, legal, dispatch — as if it had done it | noOutOfSurfaceActionClaim |
onReply |
| pours other people's personal fields into one reply | minimalDisclosure — the PII cap: it counts personal FIELD names per record and demands each one came from a tool result this turn |
onReply |
| answers with nothing, leaked think-blocks, or the same line five times | emptyReply · degenerationGuard (both auto-installed) |
onReply |
| writes internal status codes and field names at the user | jargonScrub — rewrites, never vetoes |
onReplyMutate |
| breaks a rule that is about YOUR domain and nothing in this table fits | custom (§6) |
you choose |
Every row's when to reach for it is written against its neighbours. These are the five groups where reading only one row will pick the wrong kind — the four in the table below, plus the honesty cluster that follows it:
| cluster | the axis that separates them |
|---|---|
requiresBefore · precondition |
which call came first, vs what state the world is in |
forbidThisTurn · noDuplicateCall |
the first call is illegitimate, vs only the repeat is |
confirmFirst · consentRequired · pendingConfirmMustAsk |
evidence in the CONVERSATION (an earlier turn) · a standing flag in the WORLD · gating the REPLY rather than the call |
replyMustMention · replyConfirmsLabels |
any one keyword suffices, vs every label is required |
And the honesty cluster — four kinds that all mean "the reply lied", separated by what it lied about:
noFabricatedSuccess a tool YOU own did not succeed this turn → do not claim its effect
destructiveClaimRequiresSuccess …and the effect was DESTRUCTIVE → attempt-keyed, sentence-scoped
noOutOfSurfaceActionClaim the tool is not on this agent's surface → it was never yours to do
noFalseFailureClaim the work SUCCEEDED and the reply claims it failed ← the mirror image
The first three catch invented success; the fourth catches invented failure. They are written not to
double-fire: noOutOfSurfaceActionClaim stops at the surface boundary the owned-action kinds start
at, and noFalseFailureClaim only adjudicates a turn that mutated the world.
It is exported and public, but it is a helper, not a factory: it returns a string, not a Guard,
so it has no catalog row.
function canonArgs(v: unknown): string // key-order-independent canonical fingerprintsignature, from looprun
noDuplicateCall does not compare argument objects — it compares canonArgs(args), so key order is
not identity and a re-ordered retry is still the same call:
/** Key order is not identity: to `noDuplicateCall`, both of these are the SAME call. */
export const sameCallFingerprint =
canonArgs({ start: '2026-03-02T10:00', title: 'Standup' }) === canonArgs({ title: 'Standup', start: '2026-03-02T10:00' });
/** …and a different VALUE is a different call, so a corrected retry is never denied as a repeat. */
export const differentCallFingerprint =
canonArgs({ title: 'Standup' }) !== canonArgs({ title: 'Stand-up' });excerpt · snippets/04-guards.ts
maxCalls deliberately does not use it. It counts successful calls by TOOL NAME within its
scope, arguments ignored — which is what you want from a budget:
noDuplicateCall keyed on (tool, canonArgs(args)) a rephrased retry is a DIFFERENT call → allowed
maxCalls keyed on (tool) a rephrased retry is the same tool → still burns budget
So the two are complementary rather than redundant: the escape hatch out of one is closed by the
other. Reach for canonArgs directly when a custom guard of yours needs to decide whether two
calls are "the same" — using the same fingerprint means your rule and noDuplicateCall cannot
disagree about it.
Grouped by the hook each one is installed on, because the hook decides what a rule can see and therefore what it can enforce (chapter 03 §8). 15 preTool · 1 postTool · 14 onReply · 1 onReplyMutate · 1 escape hatch.
A fourth hook exists and has no section here: onInput fires before the model runs, and §1's
matrix makes it legal for every spatial/input/run guard — but no shipped kind is installed
there, because a rule that can refuse the whole turn before a call is even proposed is a domain
decision, not a default. custom is how you reach it.
A call has been proposed and not yet executed. A deny returns to the model AS the tool result, in the governance envelope, and the model retries inside the same generation — so the correction text is written as an instruction. Nothing has happened to the world yet, which is why every gate that must PREVENT something lives here.
| factory | file | what it enforces |
|---|---|---|
requiresBefore |
flow.ts |
A tool may run only after every named dependency has already run successfully this conversation. |
forbidThisTurn |
flow.ts |
An unconditional deny of the bound tool while the binding is installed — the first call is denied too. |
maxCalls |
flow.ts |
A tool may succeed at most n times per turn (default) or per conversation. |
noDuplicateCall |
flow.ts |
Denies a call whose tool and canonical arguments already succeeded earlier in the same turn. |
argRequired |
args.ts |
The named argument must be present and non-empty (a blank string counts as missing). |
argAbsent |
args.ts |
The named argument must not be passed at all. |
argFormat |
args.ts |
A present, non-empty string argument must match the given pattern; absent or empty is left to argRequired. |
precondition |
world.ts |
The call is allowed only while a predicate over the host world holds. |
consentRequired |
world.ts |
A set of writes may run only while the world says this person's consent is on record. |
confirmFirst |
confirmation.ts |
A destructive tool needs the user's go-ahead from an EARLIER turn — via a confirm flag probe or a prior ask. Passing a mechanism NAME to the string overload throws at construction. |
noActAfterAskSameTurn |
confirmation.ts |
Denies the listed tools on a turn in which the model already asked the user a question. |
destructiveThrottle |
confirmation.ts |
At most one destructive action that TOOK EFFECT per turn (a confirmation probe does not count). |
noInstructionFromData |
reply.ts |
Denies the listed destructive tools while an imperative sits in the conversation's tool results and no earlier turn exposed the action to the user. |
askedEarlier |
structural.ts |
A gated argument may be recorded only when an askUser succeeded in an EARLIER turn; a same-turn ask does not count. |
confirmedNeedsEarlierProbe |
structural.ts |
A confirmed:true call is denied unless the SAME tool ran as a probe (confirmed!=true, matching args) in an EARLIER turn. |
A tool may run only after every named dependency has already run successfully this conversation.
When to reach for it. An ordered flow where a step is meaningless without its predecessors — bind one gate per downstream tool naming all of them. Use this for "which call came first", not for "what state is the world in" (that is precondition).
requiresBefore(['findBooking'])An unconditional deny of the bound tool while the binding is installed — the first call is denied too.
When to reach for it. A tool must be off, no matter what. Its scope is the BINDING'S LIFETIME — the check is () => reason, with no turn logic in it at all, so the ban holds for as long as the binding is installed (the name is historical). It is not a repeat detector: reach for noDuplicateCall when the FIRST call is legitimate and only the repeat is not.
forbidThisTurn('Do not reschedule while a cancellation is pending — resolve that first.')A tool may succeed at most n times per turn (default) or per conversation.
When to reach for it. A bulk cap on a tool that is legitimate but expensive or nagging — sweeps, notifications, repeat contact. Pick scope: 'conversation' for retention-style limits, scope: 'turn' for per-answer budgets.
maxCalls('sendEmail', 1, 'You already emailed this person.', { scope: 'conversation' })Denies a call whose tool and canonical arguments already succeeded earlier in the same turn.
When to reach for it. Always on (the spec class auto-installs it): it stops the same-turn retry loop where a model re-reads an identical query hoping for a different answer. Cross-turn repeats stay legal — a later turn is a genuine refresh.
noDuplicateCall()The named argument must be present and non-empty (a blank string counts as missing).
When to reach for it. A field the tool cannot do its job without, and the model tends to omit or fill with whitespace. For a field that must be well-FORMED rather than merely present, add argFormat.
argRequired('bookingId')The named argument must not be passed at all.
When to reach for it. A parameter the model keeps inventing for this tool, or the excluded half of a mutually exclusive pair — bind argAbsent on each side of the pair.
argAbsent('customerEmail')A present, non-empty string argument must match the given pattern; absent or empty is left to argRequired.
When to reach for it. The value has a shape the model can plausibly fabricate — an id, a date, a code. Compose it with argRequired when the field is also mandatory; alone it only polices the values that are actually sent.
argFormat('bookingId', '^BK-\\d{6}$')The call is allowed only while a predicate over the host world holds.
When to reach for it. A gate whose discriminator lives in WORLD state, not in this call — the predicate never sees the acting call's arguments. If the discriminator is in the args, use custom instead.
precondition((world) => world.accountActive === true, 'This account is closed — you cannot act on it.', 'act on an account only while it is open')A set of writes may run only while the world says this person's consent is on record.
When to reach for it. Storing, sharing or transmitting personal data. It is precondition specialised to a TOOL SET, which is what makes the consent posture auditable in a spec header; pair it with a conversation-scoped maxCalls for repeat contact.
consentRequired({ tools: ['storeProfile'], consentOk: (world) => world.consentOnRecord === true, reason: 'No consent on record — ask for it before storing anything.' })A destructive tool needs the user's go-ahead from an EARLIER turn — via a confirm flag probe or a prior ask. Passing a mechanism NAME to the string overload throws at construction.
When to reach for it. The user must have agreed before this call runs, and the evidence has to be cross-turn — this is the consent gate itself. Its neighbours answer different questions: destructiveThrottle caps the blast radius of a turn that IS approved, consentRequired reads a standing world flag rather than the conversation, and pendingConfirmMustAsk gates the REPLY rather than the call. Mechanism: 'arg' when the tool carries a confirm flag, 'prior-ask' when it has none and an earlier question is the only possible evidence; the string overload sets the FLAG NAME, so confirmFirst('prior-ask') throws rather than silently building a guard that can never fire.
confirmFirst('confirmed')Denies the listed tools on a turn in which the model already asked the user a question.
When to reach for it. The mirror image of confirmFirst's cross-turn requirement: it closes the multi-tool step that asks and executes back to back, which reads as "asked" but never gave the user a chance to answer.
noActAfterAskSameTurn(['cancelBooking'])At most one destructive action that TOOK EFFECT per turn (a confirmation probe does not count).
When to reach for it. Auto-installed alongside confirmFirst. It is the blast-radius cap, not a consent gate: it stops chained destructive calls in one turn even when each one is individually confirmed.
destructiveThrottle(['cancelBooking', 'refundOrder'])Denies the listed destructive tools while an imperative sits in the conversation's tool results and no earlier turn exposed the action to the user.
When to reach for it. Tool results can carry attacker-controlled text (notes, messages, tickets). The proxy is deliberately conservative — it converts a poisoned same-turn request into the legal ask-then-act two-turn flow.
noInstructionFromData({ tools: ['cancelBooking'], instructionRe: /please cancel|delete all/i })A gated argument may be recorded only when an askUser succeeded in an EARLIER turn; a same-turn ask does not count.
When to reach for it. A value the agent must not write until it has asked the operator for it and they answered in a later message — the structural replacement for a hand-written regex over "did we ask?". It keys on the presence of the gated arg plus an earlier-turn askUser, never on any text.
askedEarlier({ tool: 'completeMaintenance', arg: 'condition' })A confirmed:true call is denied unless the SAME tool ran as a probe (confirmed!=true, matching args) in an EARLIER turn.
When to reach for it. A destructive tool that carries its own confirm flag and must be previewed before it is confirmed — the preview and the go-ahead have to live in different messages. Pins the probe to the same act by args equality, all structural: no text is matched.
confirmedNeedsEarlierProbe({ tools: ['chargeDeposit'] })The only hook that sees ctx.result. It cannot veto anything — the effect already happened — so its job is to stop the RESULT from being reported as something it was not: a violation here joins the reply redrive set.
| factory | file | what it enforces |
|---|---|---|
resultInvariant |
world.ts |
A post-execution check on the tool RESULT: when the predicate fails, the violation joins the reply redrive set. |
A post-execution check on the tool RESULT: when the predicate fails, the violation joins the reply redrive set.
When to reach for it. The call already ran and cannot be undone, but its result must not be reported as if it satisfied the request — an empty report, a partial write. It never vetoes the call; it corrects the reply.
resultInvariant((result) => Array.isArray(result) && result.length > 0, 'The search returned nothing — say so instead of summarising it.', 'report an empty result as empty')The reply text is in ctx.reply and no tool can run any more. A deny costs a bounded no-tools re-generation and, if that still violates, the deterministic honest closure — so these kinds are written to fire on what was ASSERTED, never on what was merely mentioned.
| factory | file | what it enforces |
|---|---|---|
pendingConfirmMustAsk |
confirmation.ts |
When a probe returned requiresConfirmation this turn and nothing resolved it, the reply must relay that question. |
noFabricatedSuccess |
honesty.ts |
The reply may not claim a tool's effect, cite an invented artifact label, or use a banned phrase when the tool did not succeed this turn. |
destructiveClaimRequiresSuccess |
honesty.ts |
A declarative claim that a destructive action happened is denied unless one actually took effect this turn. |
noFalseFailureClaim |
honesty.ts |
When every domain call this turn succeeded and one of them mutated the world, the reply may not claim inability. |
noOutOfSurfaceActionClaim |
honesty.ts |
A declarative claim of an action whose tool is not on this agent's surface is denied. |
noUngroundedRegulatedFigure |
honesty.ts |
A figure or conclusion of a regulated class may appear only when a tool returned it this turn. |
noCompetitorClaim |
honesty.ts |
Within one sentence, a named third party plus comparative phrasing or a comparative figure is denied. |
replyMustMention |
reply.ts |
The reply must contain at least one of the given keywords (case-insensitive). |
replyMaxOccurrences |
reply.ts |
At most n DISTINCT calls-to-action from the list may appear in one reply. |
replySingleQuestion |
reply.ts |
The reply must carry exactly one question mark. |
replyConfirmsLabels |
reply.ts |
The reply must be non-empty and name every one of the given labels. |
emptyReply |
reply.ts |
The final reply must not be blank. |
degenerationGuard |
reply.ts |
Catches leaked reasoning or tool markup, chat-template tokens and run-away line repetition in the reply. |
minimalDisclosure |
reply.ts |
Caps how many records' personal FIELDS one reply may carry, and requires each named field to have been returned by a tool this turn. |
When a probe returned requiresConfirmation this turn and nothing resolved it, the reply must relay that question.
When to reach for it. The world runs the two-step protocol itself: the tool answers "I need confirmation" and the risk is a reply that summarises the action as done. It gates the REPLY; confirmFirst gates the call.
pendingConfirmMustAsk({ askRe: /shall I|do you want me to/i })The reply may not claim a tool's effect, cite an invented artifact label, or use a banned phrase when the tool did not succeed this turn.
When to reach for it. A tool whose output the model likes to announce before it exists. Arm only the seams you need — claim language, label existence, or an unconditional ban — and ship banProse with every banRe.
noFabricatedSuccess('generateReport', { reason: 'No report was generated this turn — do not say one was.', claimRe: /report is ready/i })A declarative claim that a destructive action happened is denied unless one actually took effect this turn.
When to reach for it. The destructive counterpart of noFabricatedSuccess, attempt-keyed and sentence-scoped: with no attempt this turn a destructive verb is read-back status and is left alone, and questions or offers never count as claims.
destructiveClaimRequiresSuccess(['cancelBooking'], { claimRe: /cancelled/i, askRe: /shall I cancel/i, offerRe: /would you like/i })When every domain call this turn succeeded and one of them mutated the world, the reply may not claim inability.
When to reach for it. The work actually went through and the model still apologises for failing — the mirror image of the fabricated-success kinds, which catch the opposite lie. It only adjudicates a turn that MUTATED the world, so an honest "I could not find it" on a read-only turn is never touched. Auto-installed when the lexicon supplies the pattern; keep that pattern to attempted-work-failure phrasing ("failed to", "went wrong"), since a broad inability regex would veto honest policy refusals.
noFalseFailureClaim({ claimRe: /failed to|something went wrong/i })A declarative claim of an action whose tool is not on this agent's surface is denied.
When to reach for it. The agent is expected to hand off (billing, legal, dispatch) and the model promises the handoff as done. It deliberately stops at the surface boundary, so it never double-fires with the owned-action honesty kinds.
noOutOfSurfaceActionClaim({ actionClaims: [{ claimRe: /refund (?:has been )?issued/i, tool: 'issueRefund' }], surface: ['findBooking'] })A figure or conclusion of a regulated class may appear only when a tool returned it this turn.
When to reach for it. Legal, medical or financial surfaces. Keep allowFromToolResults true when a tool is authoritative for the class; set it false to ban the class outright where nothing in the world can license it.
noUngroundedRegulatedFigure({ regulatedRe: /\b\d+\s?mg\b/i, allowFromToolResults: true })Within one sentence, a named third party plus comparative phrasing or a comparative figure is denied.
When to reach for it. Any user-facing sales or support surface. The figure branch is sound by construction: no tool returns a competitor's numbers, so any such number is invented.
noCompetitorClaim({ competitorRe: /\bAcme\b/i, comparativeRe: /\b(?:better|cheaper|faster) than\b/i })The reply must contain at least one of the given keywords (case-insensitive).
When to reach for it. A mandatory element of coverage that is the same on every turn — a referral phrase, a required disclaimer. For "name the records you just acted on", use replyConfirmsLabels instead.
replyMustMention(['support@example.com'], 'Give the support address so the person can follow up.')At most n DISTINCT calls-to-action from the list may appear in one reply.
When to reach for it. Anti-nag: the model stacks several different asks onto one message. It counts distinct entries, not repetitions, so one CTA restated inside a single ask still passes.
replyMaxOccurrences(['book now', 'call us', 'subscribe'], 1, 'One ask per reply — drop the extra calls-to-action.')The reply must carry exactly one question mark.
When to reach for it. Recovery and clarification turns, where a pile of questions at once is what stalls the conversation. Bind it to the layer that handles those turns, not to every reply.
replySingleQuestion('Ask exactly one question so the person can answer it.')The reply must be non-empty and name every one of the given labels.
When to reach for it. The model just acted on identified records and the user needs to see WHICH ones. Unlike replyMustMention (any one keyword), every label is required.
replyConfirmsLabels(['BK-100234'], 'Name the booking you acted on so the person can check it.')The final reply must not be blank.
When to reach for it. Always on (auto-installed). It is the floor under every other reply kind: a turn that ends with nothing said is a failure no other guard would catch.
emptyReply()Catches leaked reasoning or tool markup, chat-template tokens and run-away line repetition in the reply.
When to reach for it. The reply is broken as an ARTIFACT rather than wrong as a claim — think blocks, tool-call markup or the same line five times over. No honesty kind fires on that, because nothing was asserted; this one catches the weak-model failure class every domain shares. Always on (auto-installed); pass selfNarrationRe to add the language-specific third-person self-narration branch.
degenerationGuard({ selfNarrationRe: /the assistant (?:then )?(?:called|checked)/i })Caps how many records' personal FIELDS one reply may carry, and requires each named field to have been returned by a tool this turn.
When to reach for it. Any surface that reads personal records. It keys on field-name tokens, never on entity mentions, so a correct multi-record summary that lists only ids and dates stays legal.
minimalDisclosure({ piiFields: ['phone', 'email'], entityIdRe: /\bCU-\d{5}\b/, maxEntities: 1 })A ReplyMutator, not a Guard: it is applied to the reply before the onReply checks run and it has no pass/fail. Bind it with spec.addMutator(...), not addGuard.
| factory | file | what it enforces |
|---|---|---|
jargonScrub |
reply.ts |
A deterministic egress rewrite of internal vocabulary into user words (word-boundary, case-insensitive). |
A deterministic egress rewrite of internal vocabulary into user words (word-boundary, case-insensitive).
When to reach for it. Internal status codes and field names leak into replies and no gate can sensibly deny them. It is a MUTATOR, not a guard: it rewrites and never vetoes, so it has no pass/fail behaviour to prove.
jargonScrub({ CANC_PEND: 'waiting to be cancelled' })One factory, and it is the only one whose hook you choose: custom follows the dim you pass it (spatial/input/run → the tool hooks, output → postTool, behavior → onReply), which is why it is listed apart from the phase sections rather than under one of them. Section 6 walks through writing one.
| factory | file | what it enforces |
|---|---|---|
custom |
custom.ts |
The escape hatch: a guard whose kind, dim, check and prose the spec author writes by hand. |
The escape hatch: a guard whose kind, dim, check and prose the spec author writes by hand.
When to reach for it. Only when no kind fits — typically a domain concept the runtime carries no vocabulary for (media, labels, provenance) read through the world's own accessors. It is the one factory whose hook YOU choose, by the dim you pass: it is classified under preTool here only because this example is a run guard. Replicate the shared kinds' exemptions, since reviewers read this code.
custom({ kind: 'imageQuotaLeft', dim: 'run', check: (ctx) => (ctx.world.imageQuotaRemaining > 0 ? null : 'No image quota left this month — say so instead of generating.'), prose: () => 'generate an image only while quota remains' })function custom(opts: {
kind: string;
dim: Dim;
check: (ctx: GuardCtx) => string | null | Promise<string | null>;
prose: () => string;
}): Guardsignature, from looprun
custom is not a lesser path — chapter 03's "never double-book" gate is one, because no runtime kind
knows what a calendar clash is. Reach for it when the discriminator is a domain concept the runtime
carries no vocabulary for, read through your world's own accessors.
Here is the second one the scheduler wants: an event that has already started is not cancelled. No
catalog kind covers it — the discriminator is in the args (which event) and in the world (its
start time), which is exactly what rules out precondition, whose predicate never sees the args.
/**
* The accessor this guard needs, named once. `AgentWorld`'s index signature would let the typo
* `snapshto()` compile (chapter 03 §7), so the read goes through a type either way.
*/
type CalendarReader = AgentWorld & { snapshot(): CalendarEvent[] };
/** The event `ctx.args.eventId` names, or `undefined`. Total: a guard's `check()` must never throw —
* the runtime does not swallow it, it attributes it and rethrows at the caller. */
function targetEvent(ctx: GuardCtx): CalendarEvent | undefined {
const eventId = typeof ctx.args.eventId === 'string' ? ctx.args.eventId : '';
return (ctx.world as CalendarReader).snapshot().find((e) => e.id === eventId);
}
export function noCancelAfterStart(now: string): Guard {
return custom({
kind: 'noCancelAfterStart',
dim: 'run',
check: (ctx) => {
const event = targetEvent(ctx);
if (!event || event.start > now) return null;
return `"${event.title}" (${event.id}) started at ${event.start} — it is too late to cancel it. Say so and offer to remove the remaining time instead.`;
},
prose: () => 'an event that has already started is never cancelled — say it is too late and offer what can still be done',
});
}excerpt · snippets/04-guards.ts
/** Binding it: a subclass, so the shared `schedulerSpec` of chapters 02–03 keeps its own surface. */
export class LateCancelSchedulerSpec extends SchedulerSpec {
constructor() {
super();
this.addGuard('preTool', ['cancelEvent'], noCancelAfterStart(REFERENCE_NOW), { id: 'agent:noCancelAfterStart' });
}
}excerpt · snippets/04-guards.ts
Five rules, learned from the shipped kinds, that a reviewer will look for:
| rule | why |
|---|---|
dim must match what check reads |
it is a claim, and addGuard holds you to it. This one reads ctx.args + ctx.world ⇒ run |
check must be pure and total |
no clock, no randomness, no network, no LLM call — and no throw. Same inputs, same verdict, forever, or a failing eval case is not reproducible |
check must not read the user's text |
it is not in GuardCtx at all, and reaching for it through world re-opens the magnet firewall by hand |
prose() states the RULE, not the incident |
present tense, no accusation: it renders into every prompt, including turns where nothing went wrong |
| replicate the exemptions the shared kinds have | e.g. a reply-side rule that fires on questions and offers as if they were claims is a rule that punishes good behaviour |
A guard that throws is an author bug, and the runtime treats it as one. It is neither a deny nor
an allow, so nothing is guessed: AgentSpecBase wraps every binding, and a check()/prose() that
throws is re-thrown as a GuardExecutionError naming the hook, the binding id, the kind and the
tool — out of runSpecConversation, loud and addressed, instead of being buried as a model failure.
You are not expected to catch it; you are expected to fix the guard, which is why the class ships on
@looprun-ai/core/internal rather than the public barrel. Write the total function instead: the
typeof … === 'string' ? … : '' read above is what "total" costs.
Guard kind · dim · check(ctx) → deny string | null · prose() → the prompt line
GuardCtx args · tool · world · observed · turnIndex · reply · result (never the user text)
ObservedCall name · args · ok · turnIndex · resultFlags · tookEffect
Dim spatial | input | run | output | behavior → which hooks are legal
canonArgs the key-order-independent call fingerprint — `noDuplicateCall` keys on it, and
`pendingConfirmMustAsk` keys on it with the confirm flag stripped
30 factories, grouped by the hook they run on:
preTool 13 prevent it — the deny returns as the tool result, the model retries
postTool 1 the result is in; correct the REPLY, not the call
onReply 14 the reply exists; a deny costs a re-generation, then the honest closure
onReplyMutate 1 rewrite, never veto
custom 1 the escape hatch — you pass the dim
You now have the map, the machine and the rules. Chapter 05 runs all three over a scripted conversation and turns "it seemed fine" into a number you can re-run.