veragent
v0.6.1
Published
Vendor-neutral control plane client for AI agents — capture and govern agent activity. Node/TypeScript SDK.
Maintainers
Readme
Veragent Node SDK
The client library that makes Veragent a vendor-neutral control plane for agents built in JavaScript/TypeScript — the Node peer of the Python SDK, speaking the exact same wire contract so your backend treats both identically.
The core is dependency-free (Node built-ins only: fetch, AsyncLocalStorage). Requires Node 18+.
Install
npm install veragentQuick start
import { Veragent } from "veragent";
const va = new Veragent({ agent: "support-agent" }); // reads VERAGENT_API_KEY
// Report any action. Non-blocking — buffers and returns immediately.
va.track("refund issued", {
eventType: "tool_call", // the governable surface
tool: "issue_refund",
inputs: { orderId: "A-1001", amount: 49 },
});Group a run
Nested track() calls inside run() automatically inherit one correlation id (via AsyncLocalStorage):
await va.run("nightly-job", async () => {
va.track("step one", { eventType: "tool_call", tool: "fetch" });
va.track("step two", { eventType: "tool_call", tool: "write" });
});Wrap a function
const issueRefund = va.trackAction("issue_refund", async (orderId: string, amount: number) => {
// ... your logic ...
return { ok: true };
});Authorize — ask permission before acting
va.authorize(...) is the pre-action decision point. It calls the live /api/authorize endpoint with the same agent name + API key you configured the client with — you never re-pass credentials. It stays inert (reporting-only, allowed: true) until you turn enforcement on, so it's safe to wire in first:
const va = new Veragent({ agent: "crypto-paper-trader", enforcementEnabled: true });Blocking (the simple case)
const decision = await va.authorize("polymarket.place_order", {
context: { market: "BTC-100k", size: 250 },
});
if (decision.allowed) {
placeOrder(); // ✅ authorized
} else {
// MUST handle the not-allowed branch — these mean different things:
if (decision.status === "denied") {
console.warn(`blocked by policy: ${decision.reason}`);
} else if (decision.status === "timed_out") {
console.warn("no human answered in time; server fail mode applied");
} else if (decision.status === "error") {
console.error(`couldn't reach Veragent: ${decision.reason}`);
}
skipOrRollback();
}If the action is auto-decided, the answer comes back on that first call — one round trip, no polling. If it escalates to a human, authorize() blocks and polls until the decision is terminal or timeoutSeconds (default 300, server clamps 10–3600) elapses.
Async (high-throughput loops)
Don't block a hot loop on a human. Fire the request, do other work, poll later:
const decision = await va.authorize("refund.issue", {
context: { order: "A-1001" },
wait: "async",
}); // returns immediately — terminal OR status === "pending"
// ...later, poll yourself:
const latest = await va.getDecision(decision.decisionId);
if (latest.status === "pending") {
// still waiting on a human; check again later
} else if (latest.allowed) {
issueRefund();
}The decision object
interface Decision {
allowed: boolean; // the one thing you must check
status: string; // "allowed" | "denied" | "timed_out" | "error" | "pending"
decisionId: string;
reason: string;
decidedBy?: string | null;
resolvedAt?: string | null;
}Pass { raiseOnDeny: true } to throw PolicyDenied on a terminal not-allowed decision instead of returning it.
Treat
authorize()as fallible. It is a network call. A client-side inability to reach Veragent returnsstatus: "error", allowed: false(fail-safe — never silently "allowed"), which is not the same as a policydenied. Distinguishdenied(a real no) fromtimed_out(nobody answered → the server's fail mode decided) fromerror(couldn't get an answer at all). Preferwait: "async"for high-throughput loops, and keep privileged/irreversible actions late and rollback-able so a denial or error is cheap to honor. Unliketrack()(which drops failures silently), authorize failures are legible — surfaced in the returnedDecision.
Acting for a human: on_behalf_of
context.on_behalf_of is a reserved-by-convention context key naming the human principal the agent is acting for — "this agent acted on behalf of user X". context runs through the same redactor as inputs/outputs before it leaves the process (see Redaction-first), and is then stored verbatim on the decision record. Governance rules can condition on context.on_behalf_of today (any operator, e.g. escalate when an agent acts for no named principal).
const decision = await va.authorize("Refund €420 to customer 8821", {
permission: "PAYMENT",
context: { on_behalf_of: "[email protected]", amount: 420 },
});Two rules of the road:
- Opaque and yours. An email, an IdP subject id, any stable string — pick one form per organisation and keep it stable.
- Attested by the caller, never verified by Veragent. The platform cannot resolve your identity provider; this is attribution evidence on the decision record, not authentication.
Emitting the same key in track() payloads is encouraged for consistency — but pass it in metadata, not inputs:
va.track("refund issued", { metadata: { on_behalf_of: "[email protected]" } });metadata keys land at the top level of the event (metadata.on_behalf_of), which is where event-side rules and budgets read them. inputs is stored nested, at metadata.inputs.*, and is not reachable by key name — so a rule or budget keyed on a name you sent inside inputs will never match.
Instrument Vercel AI SDK tools
Wrap the tools you pass to generateText / streamText — every tool with an execute function is captured, with no changes to the tools themselves:
import { generateText } from "ai";
import { instrumentTools } from "veragent/vercel-ai";
const result = await generateText({
model,
tools: instrumentTools(myTools, va), // capture every tool call
// tools: instrumentTools(myTools, va, { enforce: true }), // also gate them
prompt: "...",
});Tools without a callable execute (provider-executed or client-side tools) are returned uninstrumented: their calls are neither captured nor gated, including under enforce: true. Instrument the tools you need governed on the server side.
Tool execution is the governable surface, so calls are tracked as eventType: "tool_call". With enforce: true, each call is authorized first and a denied one is blocked before it runs. The adapter has no dependency on the ai package (it duck-types the tools object) and preserves your tools type.
Instrument MCP tool calls
Wrap an MCP Client so every tool call through it is captured (and optionally gated):
import { instrumentMcpClient } from "veragent/mcp";
instrumentMcpClient(client, va); // capture every tool call
// instrumentMcpClient(client, va, { enforce: true }); // also gate each callMCP is the framework-agnostic tool layer, so instrumenting client.callTool captures the governable surface no matter what drives the client. With enforce: true, a denied call is blocked before it runs (returns an isError result). Dependency-free — it duck-types the client.
Instrument LangChain.js
instrument(va) returns a LangChain callback handler — pass it in the callbacks array:
import { instrument } from "veragent/langchain";
await chain.invoke(input, { callbacks: [instrument(va)] });Every LLM call, tool call, and error in the run is captured; tool calls as eventType: "tool_call". Dependency-free (it's a plain callback-methods object, no @langchain/core import). Capture-only — callbacks fire alongside execution, not before it, so use authorize() or the MCP interceptor for enforcement.
Instrument the OpenAI Agents SDK
instrument(va) registers a Veragent tracing processor with @openai/agents, so every span the SDK emits is captured:
import { instrument } from "veragent/openai-agents";
instrument(va); // capture every span the Agents SDK emits
// ...then run agents as usual.Spans map onto events by type: function → tool_call, generation/response → llm_call, agent → lifecycle, handoff/guardrail → decision, anything else → action. Registration is additive (addTraceProcessor), so existing processors keep running. Dependency-free — it duck-types the SDK's TracingProcessor/Span shapes (the package is only needed at runtime). Capture-only by design: the tracing API notifies on span end, after the action happened, so there is no pre-action hook to gate against — use authorize() or the MCP interceptor for enforcement. Every span mapping is wrapped so a capture error never throws into your agent run.
Failure semantics
What happens when things go wrong is a first-class part of the contract — read this before you enforce. There are three distinct layers, and they fail differently on purpose.
1. Enforcement is OFF by default — this is the deliberate fail-open default. A client constructed without enforcementEnabled: true never makes an authorization network call at all: authorize() returns { allowed: true, status: "allowed" } immediately (reporting-only), and the adapters (enforce defaults to false) run every tool call. Out of the box, nothing is ever blocked. This lets you wire authorize() through your codebase safely before you are ready to enforce. Until you opt in, there is no enforcement guarantee — by design.
2. With enforcement ON, an unreachable Veragent fails safe — your action does not get a "yes". When enforcementEnabled: true and the authorize() call cannot reach Veragent (network failure or client-side timeout), the SDK returns { allowed: false, status: "error" } — it never fabricates an approval. If you honor decision.allowed (and the enforce: true adapters do — they block the call), an outage means the guarded action does not proceed. At the SDK boundary an outage is therefore fail-closed: the agent never proceeds thinking it was authorized. Distinguish the three not-allowed statuses: denied (a real policy/human no), timed_out (an escalation nobody answered — see layer 3), and error (Veragent could not be reached at all).
3. Escalation timeouts are decided server-side by the matched policy's fail mode. When a policy escalates an action to a human and no one answers before the deadline, the outcome is governed by that policy's per-rule fail mode, configured in the dashboard — not by the SDK:
- fail_closed — on timeout the request resolves denied. Use it for high-stakes, low-frequency actions (a wire transfer should not go through because an approver was at lunch).
- fail_open — on timeout the request resolves allowed. Use it for high-frequency actions where blocking the agent does more harm than the occasional un-reviewed pass.
The SDK reports this back as status: "timed_out" with allowed reflecting the policy's fail mode.
Plan note. Enforcing escalate-to-human rules require the Starter plan or above. On Free, allow/deny enforcement and shadow mode work in full, but an escalate rule cannot be promoted to enforce — so
timed_outand the fail-mode branch above only arise from Starter upward.
The enforce-mode default stays fail-open at the enforcement toggle (layer 1) and fail-safe at the network boundary (layer 2). Keep privileged or irreversible actions late and rollback-able so that honoring a
deniedor anerroris cheap. Unliketrack()(which drops failures silently so it can never slow your agent),authorize()failures are always legible — surfaced in the returnedDecision, never swallowed.
Design guarantees
- Safe.
track()only buffers; all network I/O is async and off your code path. Instrumentation never blocks, slows, or throws into your code path. If Veragent is unreachable, events are dropped with a log line — your agent keeps running. (The ingest endpoint accepts one event per POST and rate-limits at 60 requests/min per key;maxBatchsets the flush trigger, not a multi-event request.) - Non-blocking. Events flush on an interval / size threshold and once on
beforeExit. Callawait va.close()for a deterministic final flush (e.g. before a short-lived process exits). - Redaction-first. Inputs/outputs run through a redactor before leaving the process (sensitive keys masked, long strings truncated). On by default; pass
redact: falseor a custom function to override. - Enforcement-ready.
va.authorize(action, { context })is the pre-action decision point, calling the live/api/authorizeendpoint. It is safe to wire in today — whileenforcementEnabledis off it returnsallowed: true(reporting-only); turn it on to get real allow/deny verdicts. Unliketrack(), authorize failures are legible, not silent: if Veragent can't be reached you getstatus: "error", allowed: falseso the agent never proceeds thinking it was authorized. See Authorize. - Wire-compatible. Emits the same
{ agent, action, severity, metadata }envelope as the Python SDK, taggedsdk: "veragent-node/<package version>".
Configuration
new Veragent({
apiKey, // or env VERAGENT_API_KEY
agent: "agent",
endpoint, // default https://www.veragent.io/api/ingest-event
redact: true, // true | false | (value) => value
flushInterval: 2, // seconds
maxBatch: 50,
maxQueue: 10000,
timeout: 10, // seconds
enforcementEnabled: false, // opt in to real allow/deny — see Failure semantics
});Adapter roadmap
| Surface | Status |
|---|---|
| Core (track, run, trackAction, authorize) | ✅ shipped |
| Vercel AI SDK (instrumentTools) | ✅ shipped |
| LangChain.js (instrument callback) | ✅ shipped |
| MCP (TypeScript) interceptor (instrumentMcpClient) | ✅ shipped |
| OpenAI Agents SDK (tracing processor) | ✅ shipped |
Versioning & stability
This SDK is pre-1.0 (0.x), and here is exactly what that means. Within 0.x we keep the surfaces you build against stable: the Decision shape (allowed / status / decisionId / reason and the five status values), the constructor options documented above, the adapter entry points, and the wire envelope. Changes are additive — new options, new fields, new adapters. If we ever have to break one of those, the minor version jumps with a loud changelog entry; nothing breaks in a patch release.
The platform side of the contract is versioned independently and published. The failure semantics above and the decision statuses are stated at veragent.io/trust, under When the SDK cannot reach us. The server's API stability posture — additive-only within v1, the error envelope, the frozen-semantics inventory — is at veragent.io/docs/api, with the machine-readable contract served at /api/v1/openapi.yaml and rendered at /docs/api/reference. Those cover the admin plane; this SDK speaks the agent plane (/api/ingest-event and /api/authorize).
The npm and PyPI packages version independently — veragent on npm and veragent on PyPI move at their own pace and their version numbers are not meant to match. A lower number on one registry is not staleness; both speak the same wire contract, and each README states its own guarantees.
Changelog
0.6.1 (2026-07-22)
Accuracy release from a pre-launch fact check of the published package. No API changes.
- Fixed a defect that could kill your process.
instrument()for OpenAI Agents registered its trace processor without catching failure; when@openai/agentswas not resolvable the resulting unhandled rejection terminated the host under Node's default--unhandled-rejections=throw. It now reports and stays inert — which is what makes "never throws into your code path" true. - Corrected the
on_behalf_ofrecipe. The old text said to send it ininputs;inputsis stored nested atmetadata.inputs.*and is not reachable by key name, so rules and budgets keyed on it never matched. Usemetadata— it lands top-level. - Corrected the platform-docs pointer. The failure semantics and decision statuses are documented at veragent.io/trust; the previous link pointed at the Management API stability page, which documents neither.
- Removed "batched". Ingest accepts one event per POST and rate-limits at 60/min per key;
maxBatchis a flush trigger, not a multi-event request. - Corrected "context passes through verbatim".
contextis redacted client-side likeinputs/outputs, then stored verbatim. - Withdrew "zero added latency" — unmeasured, so unclaimed.
- Stated the plan requirement: enforcing escalate-to-human needs Starter or above.
- Documented a real gap: Vercel AI tools without
executeare neither captured nor gated, including underenforce: true.
0.6.0 (2026-07-22)
- Removed the
failClosedconstructor option. It was decorative from the day it shipped — assigned and never read; no fail-open path ever existed behind it. Behavior is unchanged: with enforcement on, an unreachable Veragent has always returned{ allowed: false, status: "error" }. If you passedfailClosed, delete the line — TypeScript will point at it. (This is the loud minor-version break the stability statement above describes.) - New documentation: the three-layer Failure semantics contract,
on_behalf_ofattribution, and this Versioning & stability statement. - The wire tag
sdk: "veragent-node/<version>"now tracks the package version — it had been stuck at0.2.0since 0.2.0.
