Runtime guardrails for TypeScript AI agents

Your agent retried.
The side effect ran once.

@keelstack/guard gives you production safety rails fast: deterministic idempotency, spend limits, risk gates, and failure policies. Ship now, then add @keelstack/guard-redis + OTel spans in beta before public launch.

$ npm install @keelstack/guard ⌘C Copied!
★ GitHub
37
Tests passing
98%
Stmt coverage
0
Runtime deps
22 KB
Packed size
The problem

Agent frameworks retry. Your side effects don't know that.

LangGraph, Vercel AI SDK, Mastra, OpenAI Agents SDK — they all retry failed or timed-out tool calls. Without application-level deduplication, your action fires multiple times.

✉️
Email service
Agent calls sendEmail(). Network blips at 2s. Framework retries after timeout. Email sends twice.
→ User gets 2 welcome emails.
💳
Stripe charge
Agent calls stripe.charges.create(). Stripe responds slow. Agent retries with same params.
→ Customer charged twice.
🔁
Agent loop
Prompt injection or bad instructions cause the LLM to loop. API calls stack up. Bill triples overnight.
→ $200 surprise bill.
How it works

One wrapper. Core runtime primitives.

Works with any async () => T. No framework coupling. No config to start.

import { guard } from '@keelstack/guard';

// Agent calls sendWelcomeEmail(). Network blips. Agent retries.
// Without guard → email sent twice.
// With guard    → second call returns cached result. Email sent once.

const result = await guard({
  key: `send-welcome:${userId}`,     // stable, unique per operation
  action: () => resend.emails.send({
    to: user.email,
    subject: 'Welcome to the app!',
  }),
});
executed First call — action ran, result stored in ledger.
replayed Retry with same key — cached result returned. Email NOT sent again.
const result = await guard({
  key: `ai-call:${userId}:${requestId}`,
  action: () => openai.chat.completions.create({
    model: 'gpt-4o',
    messages: [...],
  }),
  budget: {
    id: userId,           // per-user budget
    limitUsd: 2.00,      // hard cap: $2 per day
    warnAt: [0.5, 0.8],  // warn at 50% and 80%
    onWarn: ({ percentUsed, id }) =>
      console.warn(`User ${id}: ${(percentUsed * 100).toFixed(0)}% of AI budget used`),
  },
  extractCost: (res) => (res.usage.total_tokens / 1_000_000) * 15,
});

if (result.status === 'blocked:budget') {
  return Response.json({ error: 'Daily AI budget exceeded' }, { status: 429 });
}
executed Under budget — action ran, cost tracked.
blocked:budget Limit reached — action NOT executed. No API call made.
const result = await guard({
  key: `delete-account:${userId}`,
  action: () => db.users.delete({ where: { id: userId } }),
  risk: {
    level: 'irreversible',   // 'safe' | 'reversible' | 'irreversible'
    policy: 'block',         // 'allow' | 'log' | 'warn' | 'block'
    onRisk: (info) => {
      auditLog.write({
        key: info.key,
        level: info.level,
        blocked: info.blocked,
      });
    },
  },
});

if (result.status === 'blocked:risk') {
  return Response.json({ error: 'Action blocked by risk policy' }, { status: 403 });
}
blocked:risk Policy set to 'block' — action NOT executed. onRisk callback fired for audit log.
// All three primitives — idempotency + budget + risk — on one call.
const result = await guard({
  key: `agent-action:${userId}:${taskId}`,
  action: () => stripe.charges.create({
    amount: amountCents,
    currency: 'usd',
  }),
  budget: { id: userId, limitUsd: 50 },
  extractCost: (res) => res.amount / 100,
  risk: { level: 'irreversible', policy: 'log', onRisk: auditLog.write },
});

// Guards run in order: idempotency → budget → risk → execute
// result.status: 'executed' | 'replayed' | 'blocked:budget' | 'blocked:risk'
executed Passed all three checks. Charge fired. Cost tracked. Risk logged.
replayed Duplicate key on retry — cached result returned, no double-charge.
Developer experience

Key construction guide: bad vs good.

Good idempotency depends on deterministic keys. The guide below mirrors the README advice so teams avoid accidental duplicate side effects in production.

Bad key (unstable)
await guard({
  key: `welcome:${Date.now()}:${Math.random()}`,
  action: () => sendWelcomeEmail(user),
});

// Every retry generates a different key.
// Deduplication cannot replay safely.
Good key (deterministic)
await guard({
  key: `welcome-email:${user.id}`,
  action: () => sendWelcomeEmail(user),
});

// Same operation + same entity => same key.
// Retries replay the stored result instead of firing twice.
What's inside

Four primitives. One wrapper.

Idempotency, budget, risk, and FailureConfig policies in a tiny surface area. Production behavior without framework lock-in.

01 ·
Idempotency gate
Repeated calls with the same key replay the stored result instead of executing the action again. Works across retries, parallel subagents, and workflow resumes.
status: replayed
02 ·
Budget enforcer
Per-user spend tracking with configurable warn thresholds and a hard block. Supports any cost extraction — LLM tokens, API call cost, custom formula.
status: blocked:budget
03 ·
Risk gate
Classify actions as safe, reversible, or irreversible. Policy controls what happens: allow, log, warn, or block. onRisk callback fires for every classified action.
status: blocked:risk
04 ·
Failure handling (FailureConfig)
Choose policy per action: rethrow, return, swallow, or compensate. The compensate policy runs a compensation callback before rethrowing so you can undo external side effects cleanly.
policy: compensate
Compatibility

Framework-agnostic. Works with anything.

No coupling. If it returns Promise<T>, you can guard it.

Vercel AI SDK
LangGraph.js
Mastra
OpenAI Agents SDK
Raw fetch / custom loops
Express / Fastify / Hono
Next.js API routes
Node ≥ 20 · TypeScript ≥ 5
"I kept watching agents send duplicate emails. So I built the wrapper I wished existed."
17
years old
Beta opening now

Get @keelstack/guard-redis + OTel spans before public launch

Join the beta to run guard with a first-party Redis adapter and native OpenTelemetry spans. Keep your idempotency ledger in Redis, trace every replay/block in your existing observability stack, and shape the dashboard with direct feedback.

Checklist: ✅ Idempotency ✅ Budget ✅ Risk gate ✅ FailureConfig · Coming: First-party Redis adapter, Dashboard, OTel spans

First-party @keelstack/guard-redis adapter OpenTelemetry spans coming next for guard outcomes Replay timeline + risk audit exports Beta users lock in pricing before public launch
✓ You're on the list. I'll reach out personally when it's ready.

Free core SDK stays MIT forever. Dashboard is the paid layer.