npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@the-bot-club/agentguard

v0.15.0

Published

AgentGuard TypeScript SDK with local policy contracts and compatibility HTTP/in-process adapters.

Readme

@the-bot-club/agentguard

TypeScript SDK 0.15.0 for AgentGuard policy evaluation and local v1 contracts. Its OpenClaw hook and MCP HTTP/in-process adapters are compatibility telemetry; they are not executor-owned firewall proof or evidence of a compliance outcome.

npm license homepage

This package is the canonical TypeScript/Zod contract line. The Python package is an independent compatibility release named agentguard-tech; it does not claim v1 runtime equivalence.

Install

npm install @the-bot-club/agentguard

Published subpaths, all of them usable from both import and require:

| Subpath | Contents | |---------|----------| | @the-bot-club/agentguard | AgentGuard client and the re-exported public surface | | @the-bot-club/agentguard/core | PolicyEngine, AuditLogger, KillSwitch, policy types | | @the-bot-club/agentguard/sdk | AgentGuard client and LocalPolicyEngine | | @the-bot-club/agentguard/contracts/v1 | Canonical v1 Zod contracts and canonicalisation | | @the-bot-club/agentguard/openclaw | OpenClaw before_tool_call compatibility hook |

Quick start

import { AgentGuard } from "@the-bot-club/agentguard";

const guard = new AgentGuard({ apiKey: process.env.AGENTGUARD_API_KEY });

const decision = await guard.evaluate({
  tool: "database_query",
  params: { query: "DROP TABLE users" },
});
// → { result: "block", reason: "Destructive SQL operation", riskScore: 95, durationMs: 1.2 }

evaluate() returns the keys POST /api/v1/evaluate returns: result ("allow" | "block" | "monitor" | "require_approval"), riskScore, reason, durationMs, and matchedRuleId when a rule matched.

Offline policy evaluation

PolicyEngine evaluates a checked-in YAML policy in process, with no API key and no network:

import { PolicyEngine } from "@the-bot-club/agentguard/core";

const engine = new PolicyEngine();
const bundle = engine.loadFromFile("./policy.yaml");

const decision = engine.evaluate(
  {
    id: crypto.randomUUID(),
    agentId: "ops-1",
    tool: "shell_exec",
    params: { cmd: "rm -rf /" },
    inputDataLabels: [],
    timestamp: new Date().toISOString(),
  },
  { agentId: "ops-1", sessionId: "session-1", policyVersion: "1.0.0" },
  bundle.policyId,
);
// → { result: "block", matchedRuleId: "...", riskScore: ..., reason: "...", durationMs: ... }

loadFromYaml(string) takes the same document as a string, and both return the compiled PolicyBundle whose policyId you pass to evaluate(). The document schema is PolicyDocumentSchema (exported from the same subpath); the policies under src/examples/policies/ in the repository are working documents.

What riskScore is

riskScore is arithmetic, not a model. It is computed as:

min(1000, round((base[result] + Σ riskBoost of matched monitor rules) × tierMultiplier))

base           allow 0 · monitor 10 · block 50 · require_approval 40
tierMultiplier low 1.0 · medium 1.5 · high 2.0 · critical 3.0

tierMultiplier comes from sessionContext.riskTier on the context you pass, and defaults to medium (1.5) when that field is absent or unrecognised — so the multiplier is only as meaningful as the tier the caller supplies, and a typo cannot quietly reduce the score.

The offline path scores identically. AgentGuardClient.evaluate() takes an optional riskTier, which LocalPolicyEngine applies through the same computeRiskScore() the embedded engine uses:

await client.evaluate({ tool: "transfer_funds", params, riskTier: "critical" });

Omit it and both paths treat the session as medium. Every input is data you already have, which means the same number is reproducible in Rego or any other policy language.

riskScore is therefore not what this engine adds over an external policy engine such as OPA. Stateless matching — conditions, priority ordering, most-restrictive conflict resolution — is expressible in Rego too. What is awkward to express there, and what this engine actually provides, are the stateful controls: rateLimit counts calls across evaluations — one counter per matched rule, keyed by that rule's keyBy (session, agent, tenant or tool), not by all four at once — and the kill switch halts an agent or the whole system between calls.

See docs/enforcement-matrix.md for exactly which policy keys are enforced, and which are carried for the caller to act on.

Which agents a policy governs (targets)

A policy's targets block names its audience. It is enforced: an agent the policy does not target is refused with POLICY_NOT_APPLICABLE (403) before any rule is looked up.

targets:
  agentIds: [billing-bot]
  agentTags: [finance]
engine.evaluate(request, { ...ctx, agentTags: ["finance"] }, policyId);

A policy applies when agentIds contains ctx.agentId, or agentTags intersects ctx.agentTags. A targets block that declares neither list, or no block at all, applies to every agent — unchanged from before.

Refusing rather than returning the policy default is deliberate: a policy whose default is allow would otherwise hand an untargeted agent an allow from a policy that was never meant to govern it. For the same reason, a policy that targets tags against a context carrying no agentTags is refused — "untagged" cannot be read as "targeted".

Check it at startup, not in production

Refusing is the right answer and it is a quiet one. A targets block that matches nothing makes every call refuse, with no rule matched — which from outside looks the same as a policy that simply blocks everything. A deployment found this the expensive way: four correct-looking lines took every read, write and operator action to denied, because its broker maps an engine throw to a block.

So ask the question before it denies anything. policyAppliesTo() is the same code evaluate() enforces with, exported and pure:

import { policyAppliesTo } from "@the-bot-club/agentguard/core";

const match = policyAppliesTo(bundle, ctx);
if (!match.applies) {
  // One legible failure at boot, naming the policy and the agent, instead of
  // every request denying for the rest of the deployment.
  throw new Error(`policy ${bundle.policyId} does not govern ${ctx.agentId}: ${match.reason}`);
}

match.reason is the sentence POLICY_NOT_APPLICABLE carries, so a startup check and a production log read identically. Calling it touches no counters, budgets or kill switch.

Policy budgets

A policy's budgets block is policy-wide and is checked before any rule is looked up, so an exhausted budget ends the call whatever rule would have matched. Every attempt counts, including one a rule blocks anyway — a budget bounds what an agent may try, not what it succeeds at.

The five budgets split by what the engine can actually see:

| Budget | Enforced from | |---|---| | maxActionsPerMinute | counted by the engine | | maxActionsPerSession | counted by the engine | | maxTokensPerSession | budgetTracker → tokensThisSession | | maxTokensPerDay | budgetTracker → tokensToday | | maxApiSpendCentsPerDay | budgetTracker → apiSpendCentsToday |

The engine sees every action, so it counts those itself. It never sees a token or a cent, so you report those. The quickest wiring is the bundled tracker plus recordUsage() after each model call:

import { PolicyEngine, InMemoryBudgetTracker } from "@the-bot-club/agentguard/core";

const engine = new PolicyEngine({ budgetTracker: new InMemoryBudgetTracker() });

const decision = engine.evaluate(request, ctx, policyId);
engine.assertAllowed(decision);

const completion = await model(request);
engine.recordUsage(ctx, {                 // what the next evaluate() will see
  tokens: completion.usage.totalTokens,
  apiSpendCents: 12,
});

InMemoryBudgetTracker tallies tokens per session, and tokens and spend per agent per UTC day. It is process-local: two replicas keep two tallies, so an agent's real allowance is the sum. Use exportState() / importState() to carry tallies across a restart, or implement BudgetTracker over your own store.

recordUsage() throws if the engine has no tracker, or the tracker is read-only — a usage report that silently goes nowhere would leave a declared budget looking enforced while counting nothing.

Bring your own store instead by implementing the interface directly:

const engine = new PolicyEngine({
  budgetTracker: {
    // Synchronous — this is the <10ms p95 hot path. Read a counter you keep
    // in memory; do not call a billing API from here.
    usage: (ctx) => ({
      tokensThisSession: tokensFor(ctx.sessionId),
      apiSpendCentsToday: spendFor(ctx.agentId),
    }),
  },
});

Exceeding a budget throws PolicyError with code BUDGET_EXCEEDED (429, retryable).

A declared budget that nothing can measure is refused, not ignored. If a policy declares maxTokensPerSession and no budgetTracker is configured — or one is configured but returns no tokensThisSession — the action is denied, with a message naming the missing input. A half-configured tracker must not read as unlimited. There is no flag to turn this off: a switch that disabled a declared control would recreate the bug it exists to fix.

Action budgets are counted in the same store as rateLimit, so they are evicted and snapshotted with it — exportState() carries them across a restart too.

Halting an embedded engine

KillSwitch is in-process: a sidecar that embeds PolicyEngine has no way to halt it from outside. createControlHandler provides that surface, with no runtime dependency and no framework assumption:

import { PolicyEngine, KillSwitch, createControlHandler } from "@the-bot-club/agentguard/core";

const killSwitch = new KillSwitch();
const engine = new PolicyEngine({ killSwitch });   // opt-in
const control = createControlHandler(killSwitch, { token: process.env.CONTROL_TOKEN! });

// Route to it from whatever server you already run:
//   GET  /state   → { globalHalt, haltedAgents: string[], globalHaltAt?, globalHaltReason? }
//   POST /halt    → { scope: "global" | "<agentId>", reason?, issuedBy? }
//   POST /resume  → { scope: "global" | "<agentId>", issuedBy? }
const res = await control({ method: "GET", url: "/state", headers: req.headers });

Every request needs Authorization: Bearer <token>; anything else is 401. The token is compared in constant time, and an empty token is rejected at construction rather than authenticating everyone.

Bind this to a private interface. It halts and resumes agents — it is a control plane, not a public API.

Passing killSwitch to PolicyEngine is opt-in. With it, evaluate() refuses a halted agent before any rule lookup, throwing PolicyError with code GLOBAL_HALT or AGENT_HALTED (both HTTP 503). Without it, evaluate() behaves exactly as before — existing callers are unaffected.

Rate-limit counters: where they live, and how to keep them

rateLimit is stateful, so the counters have to live somewhere. They live in the engine's RateLimitStore — by default an InMemoryRateLimitStore, holding them in this process's heap. Two consequences worth stating plainly:

  • They are per process. Two replicas with their own engines each enforce the limit separately, so a maxCalls: 10 rule permits up to 20 calls across two replicas. Share counters (below) if that matters.
  • They die with the process. A restart hands every limited agent a fresh budget.
import { PolicyEngine, InMemoryRateLimitStore } from "@the-bot-club/agentguard/core";

// Persist counters across a restart:
const engine = new PolicyEngine();
engine.importState(JSON.parse(await readFile("counters.json", "utf8")));  // on boot
await writeFile("counters.json", JSON.stringify(engine.exportState()));   // on shutdown

// Or supply your own storage:
const engine2 = new PolicyEngine({
  rateLimitStore: new InMemoryRateLimitStore({ maxEntries: 50_000 }),
});

importState() refuses two things on purpose: entries whose window has already closed (a stale snapshot cannot resurrect a dead window), and any count lower than a live local one (restoring state must not refund budget an agent already spent).

RateLimitStore is synchronous, because evaluate() is — a store that awaits a network round trip cannot sit on a <10ms p95 decision path. Back a durable deployment with exportState()/importState(), or put a write-behind adapter behind the interface; don't put Redis on the hot path.

The default store evicts counters whose window has closed, sweeping on write rather than on a timer (a repeating timer would keep your process alive). The sweep only ever drops closed windows — evicting a live one would reset the counter and let a limited agent burst again.

The maxEntries cap (default 100,000) is the one exception. If expiring closed windows cannot get the store under it, live counters are dropped too, soonest- closing first, and those agents regain budget early. That is a deliberate trade of exact enforcement for a bounded heap; raise maxEntries if you would rather spend the memory.

Human approval (require_approval)

A require_approval rule does not stop anything by itself. evaluate() returns a decision — it never throws — so this runs the tool the policy just held:

const d = engine.evaluate(req, ctx, policyId);
if (d.result === "block") throw new Error(d.reason);  // require_approval falls through
await tool.run();                                     // ← runs, unapproved

Two ways to get the enforcement the rule implies:

// 1. Synchronous — refuse anything that is not allow/monitor.
const d = engine.evaluate(req, ctx, policyId);
engine.assertAllowed(d);   // throws POLICY_DENIED (403) or HELD_FOR_APPROVAL (403)

// 2. Asynchronous — actually resolve the hold.
const engine = new PolicyEngine({
  approvalHandler: async (gate) => {
    // gate: { gateId, gateTimeoutSec, onTimeout, tool, agentId,
    //         sessionId, matchedRuleId, approvers, riskScore }
    return (await askAHuman(gate)) ? "approve" : "deny";
  },
});
const d = await engine.evaluateWithApproval(req, ctx, policyId);

evaluateWithApproval() is async and separate from evaluate() on purpose: waiting for a human is I/O, and evaluate() is the synchronous hot path this engine promises at <10ms p95. An engine built without approvalHandler behaves exactly as before.

For a held action:

| Situation | Outcome | |---|---| | no approvalHandler | throws HELD_FOR_APPROVAL (403) — an approval nothing can grant is a deny | | handler returns "approve" | the decision, with result flipped to allow | | handler returns "deny" | throws APPROVAL_DENIED (403) | | still pending after gateTimeoutSec | honours the rule's on_timeout: allow returns an allow decision, block (the default) throws APPROVAL_TIMEOUT (408) | | handler rejects | fails closed — APPROVAL_DENIED (403) |

Any other result (allow, monitor, block) is returned unchanged, so evaluateWithApproval() does not throw on a block — pair it with assertAllowed() if you want both enforced.

decision.onTimeout carries the matched rule's on_timeout (default block) so a caller resolving gates itself can honour it. It is non-null only while the decision is a hold — once evaluateWithApproval() resolves one to allow, it is null again. An approved decision keeps the riskScore it was evaluated with: approval records who said yes, it does not make the held action less risky.

The embedded engine has no approval UI, queue or store. approvalHandler is where you connect one. The hosted API is different: a require_approval decision there creates a pending approval record, fires an hitl webhook and returns an approvalUrl.

Every shipped framework adapter already treats require_approval as a stop — LangChain and CrewAI throw AgentGuardBlockError, the A2A and AutoGen guards mark the call blocked, and the OpenClaw hook refuses the proposal.

OpenClaw compatibility hook

The included plugin registers a structural before_tool_call hook at priority 100. When OpenClaw proposes a tool call, the hook sends the observed proposal to POST /api/v1/evaluate on the AgentGuard API, authenticated with the X-API-Key header, and maps the returned decision back to OpenClaw: allow and monitor let the proposal through, block and require_approval stop it.

Strict mode is required and returns a block result on API failure. strict: false allow-on-error is a hard startup error. The hook can be disabled or bypassed, owns no separate raw capability, and is ineligible for firewall proof.

{
  "plugins": {
    "entries": {
      "agentguard": {
        "enabled": true,
        "config": {
          "apiKey": "${AGENTGUARD_API_KEY}",
          "agentId": "my-agent",
          "strict": true
        }
      }
    },
    "installs": {
      "agentguard": {
        "source": "npm",
        "spec": "@the-bot-club/[email protected]"
      }
    }
  }
}

OpenClaw-provided run, session, and tool-call IDs are correlation data only. The source also contains MCP HTTP proxy and in-process wrapper adapters with the same compatibility-only assurance limit.

Telemetry

Telemetry is opt-in and off by default. Nothing is sent unless you construct the client with telemetry: true:

const guard = new AgentGuard({ apiKey, telemetry: true });   // opt in
const guard = new AgentGuard({ apiKey });                    // default: nothing sent

When you opt in, the SDK sends one anonymous ping to POST {baseUrl}/api/v1/telemetry per client instance, on the first evaluate() call. The payload is exactly four fields:

| Field | Value | |-------|-------| | sdk_version | this package's version, e.g. 0.15.0 | | language | node | | node_version | process.version, e.g. v24.20.0 | | os_platform | os.platform(), e.g. linux |

No API key, tool name, parameters, policy, decision, hostname, or IP-derived identifier is included, and the ping is fire-and-forget — it never throws and never delays an evaluation.

Setting the environment variable AGENTGUARD_NO_TELEMETRY=1 force-disables the ping even when telemetry: true is passed.

This is separate from your own audit trail: in localEval: true mode the client batches decision records to POST /api/v1/audit under your API key. That is your tenant's data, not usage telemetry, and it is not controlled by this setting.

Documentation

Licence

Business Source License 1.1. The LICENSE file shipped in this package is authoritative. © 2026 The Bot Club Pty Ltd (ABN 99 695 980 226).