@the-bot-club/agentguard
v0.15.0
Published
AgentGuard TypeScript SDK with local policy contracts and compatibility HTTP/in-process adapters.
Maintainers
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.
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/agentguardPublished 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.0tierMultiplier 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: 10rule 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, unapprovedTwo 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 sentWhen 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
- Website — agentguard.tech
- Docs — agentguard.tech/docs/
- OpenClaw integration — agentguard.tech/openclaw/
- Source — agentguard.tech/docs/#source
- Support — [email protected]
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).
