just-another-sdk

Guardrails

Input and output validation, tool gating, and human approval.

Three places to say no, and one place to ask a person.

const agent = new Agent({
  name: 'support',
  model,
  tools: [refundOrder],

  inputGuardrails: [
    {
      name: 'max-length',
      check: (input) =>
        input.length > 10_000
          ? { reject: 'Message too long.' }
          : { allow: true },
    },
  ],

  outputGuardrails: [
    {
      name: 'pii-scrub',
      check: (text) => ({ allow: true, replace: stripPII(text) }),
    },
  ],

  toolGuardrails: [
    {
      name: 'refund-cap',
      tools: ['refund_order'],
      check: ({ input }) =>
        input.amount > 100 ? { requireApproval: true } : { allow: true },
    },
  ],
})

Every guardrail can allow, rewrite, or reject. Tool guardrails can also require approval, which suspends the run for a human.

Why they carry a name

The name is not decoration. A triggered guardrail shows up in the event stream and the trace, and guardrail #1 triggered is useless when you are reading production logs. It is the same reason tool() takes a name.

There is no guardrail() helper to match tool(). That one exists to infer a handler's input from a schema and memoize JSON Schema derivation; a guardrail needs neither, so a plain object is the whole story.

Input guardrails

Run after any session history loads and before the first model call, so a rejection costs nothing:

const result = await agent.run(hugePaste) // throws GuardrailError
// model.calls.length === 0

They run in declaration order, one at a time, so a rewrite is visible to the next guardrail — scrub, then length-check. The first rejection wins and the rest are not called.

A rewrite replaces the run's new user turn. context.messages carries the whole conversation if you need to decide based on what came before.

Output guardrails

Run against the final answer, before it reaches you or the session:

outputGuardrails: [
  {
    name: 'pii-scrub',
    check: (text) => ({ allow: true, replace: stripPII(text) }),
  },
]

They are typed on the agent's output. With an outputSchema a guardrail receives the validated object, not raw text — so a rewrite cannot break the schema contract:

const agent = new Agent({
  name: 'triage',
  model,
  outputSchema: Ticket,
  outputGuardrails: [
    {
      name: 'escalate-vip',
      check: (ticket) => ({ allow: true, replace: { ...ticket, severity: 5 } }),
    },
  ],
})

context.text still carries the raw JSON when you want to scan it.

A rewrite updates the transcript as well as the return value. Scrubbing only what you get back would leave the original in the session and hand it straight to the model on the next turn — which would defeat the point.

Tool guardrails

Run after a tool's arguments validate and before its handler executes, so you inspect coerced values you can trust:

toolGuardrails: [
  { name: 'refund-cap', tools: ['refund_order'], check: ({ input }) => … },
  { name: 'confirm-writes', check: ({ toolName }) => … }, // every tool
]

Omit tools to gate everything. A name in tools that is not a registered tool is rejected by the Agent constructor rather than ignored — a typo in a safety control must not fail open silently.

A blocked call is not a run failure. The model receives an error result, reads it, and routes around it:

result.stopReason // 'finish' — the run completed
result.steps[0].toolResults[0].isError // true

This holds under onToolError: 'throw' too. That flag is for "a tool broke, so abort" — a guardrail refusing a call is a policy decision working correctly, not a broken tool, so it does not trip it.

Tool guardrails deliberately cannot rewrite arguments. The seam sits after the schema precisely so the values are trustworthy; writing back would either bypass that schema or need a second validation pass. Clamp in the schema (z.number().max(100)) or in the handler — a guardrail decides whether, not what.

A guardrail that throws fails closed

An exception inside check becomes a rejection, with the original error as cause. It is never treated as an allow.

This is the deliberate opposite of an onEvent listener, whose throw is swallowed, and of summarizing, which falls back to plain trimming. Those are optimisations, where losing the feature loses nothing important. A guardrail is a safety control, and a broken one must not wave traffic through.

Human approval

A requireApproval verdict suspends the run before any tool in that turn executes and throws ApprovalRequiredError:

import { ApprovalRequiredError } from 'just-another-sdk'

try {
  const result = await agent.run('Refund order 1234')
} catch (error) {
  if (!(error instanceof ApprovalRequiredError)) throw error

  const decisions = await Promise.all(
    error.suspension.pending.calls.map(async (call) => ({
      toolCallId: call.toolCallId,
      approved: await askHuman(call), // call.toolName, call.input, call.reason
    })),
  )

  const final = await agent.resumeApproval(error.suspension, decisions)
}

suspension is plain JSON. Store it, send it to another process, decide an hour later — there is no ApprovalStore to implement and no adapter per backend, because there is no state the SDK has to hold.

A denial is not an error either: the model receives your reason as a tool result and explains itself.

{ toolCallId, approved: false, reason: 'Outside policy this month.' }

resumeApproval, not resume. agent.resume(streamId) re-attaches a reader to a recorded event stream — a different thing entirely.

If any call needs approval, none of them run

A turn can hold several tool calls. If one is gated, none execute:

// The model asked for lookup_customer AND refund_order.
// Neither ran. After resumeApproval, each runs exactly once.

Running the ungated ones first would execute them again on resume — a duplicated side effect, which is the exact failure an approval gate exists to prevent.

Resume in the trace

A resumed run records the carried-over calls as a step, but not as a turn:

result.steps.map((s) => s.kind) // ['resume', 'turn']
result.turns // 1 — `maxTurns` stays the ceiling it claims to be

Security

Stateless approval means the SDK cannot authenticate a decision — that would need a shared secret or a store, and it has neither. Be clear-eyed about what it does and does not give you.

What holds structurally:

  1. Ids are not invented. A decision must reference a toolCallId that exists as a tool call in the conversation you supplied.
  2. An unknown id fails loud — a ConfigurationError listing the ids that were expected, rather than a silent re-suspend with nothing to debug.
  3. An approval authorises one call, once. Only the resume step reads your decisions; the in-loop gate never does. If the model calls the same gated tool again later in the run, it suspends again. This is a property of the code shape, not a check that can rot.
  4. An approval cannot override a reject. The guardrails are re-run on resume; approved: true satisfies a requireApproval and nothing else.

What does not hold: an attacker replaying a captured { suspension, decisions } payload against your endpoint. You own that transport.

Do not round-trip suspension through a browser. It contains the entire conversation. Persist it server-side under an id you mint, and send the client only suspension.pending — which carries the tool name, arguments, and reason, and no conversation.

For the same reason, ApprovalRequiredError.toJSON() omits the messages: that is the log-safe form the console tracer and the SSE serializer both print.

Events

EventWhen
guardrail.triggeredA guardrail rejected, rewrote, or asked for approval
approval.requiredThe run is suspending; carries the pending calls
approval.resolvedA decision was applied during a resume

guardrail.triggered deliberately never carries the value being judged — events cross an SSE boundary, and a rejected input is unbounded and often user-supplied. approval.required does carry the tool arguments, because an approval UI cannot render "approve this refund?" without them.

Testing

Everything here is testable offline with mockProvider:

it('blocks the dangerous tool but still finishes', async () => {
  const model = mockProvider([
    { toolCalls: [{ toolName: 'delete_account', input: { id: 'u1' } }] },
    { text: 'I cannot do that.' },
  ])

  const result = await new Agent({
    name: 'test',
    model,
    tools: [deleteAccount],
    toolGuardrails: [
      { name: 'read-only', check: () => ({ reject: 'Read-only.' }) },
    ],
  }).run('delete u1')

  expect(result.stopReason).toBe('finish')
  expect(result.steps[0].toolResults[0].isError).toBe(true)
})

See pnpm example:guardrails — it runs entirely offline.

Errors

ClassCodeCarries
GuardrailErrorguardrail_blockedguardrail, stage, subject
ApprovalRequiredErrorapproval_requiredsuspension

GuardrailError.subject is on the instance and not in details, so it stays out of toJSON() — same rule as InvalidOutputError.rawText.

Tool guardrails do not raise GuardrailError; a blocked call is a recoverable tool result instead.

On this page