@sevra/guard
v0.1.0
Published
The Sevra guard client. Call the control layer before an agent moves money and get Allow, Require approval, or Block, with an execution token on allow and an immutable audit trail behind every decision.
Maintainers
Readme
@sevra/guard
The Sevra guard client. Sevra is the control layer for AI agents that move money: your agent calls guard() before a refund, payout, or ledger write, and Sevra returns one of three verdicts, each recorded in an immutable audit trail.
- allow: proceed. The decision carries an
executionToken; use it as your payment provider's idempotency key so the side effect fires at most once. - require_approval: the action is held for a human. The SDK can wait for the decision for you.
- block: do not execute. Blocked, denied, timed out, and unevaluable actions all land here.
Sevra decides and audits; it never holds or moves funds.
Source of truth: this package is a copy of
src/lib/sevra/sdk/index.tsin the Sevra repo, which is what the app itself runs. Changes land there first, with tests, and are synced here before a release.
Install
npm install @sevra/guardZero runtime dependencies. Requires a runtime with global fetch (Node 18+, Deno, Bun, browsers, edge runtimes).
Auth
Every call authenticates with your per-tenant guard key in the Authorization header:
Authorization: Bearer <your guard key>The key both authenticates and resolves your tenant. Keep it server-side; treat it like any secret.
Quickstart: a refund agent
import { SevraGuard, SevraBlocked } from "@sevra/guard";
const sevra = new SevraGuard({
apiKey: process.env.SEVRA_GUARD_KEY!,
baseUrl: "https://sevra.dev",
});
async function refund(orderId: string, amountMinor: number, currency: string) {
const decision = await sevra.guard({
agent: "refund-bot",
action: "refund",
target: `order:${orderId}`,
params: { orderId, amountMinor, currency },
idempotencyKey: `refund:${orderId}`,
amountMinor,
currency,
direction: "out",
});
if (decision.verdict !== "allow" || !decision.executionToken) {
// Blocked, denied, timed out, or unevaluable. Never execute without a token.
throw new SevraBlocked(decision);
}
// Execute exactly once: the token is your provider-side idempotency key.
return stripe.refunds.create(
{ payment_intent: orderId, amount: amountMinor },
{ idempotencyKey: decision.executionToken },
);
}The one rule: only execute when verdict is "allow" and an executionToken is present. Anything else, including a thrown network error, means do not execute.
Verdicts
| verdict | status | What you do |
|---|---|---|
| allow | approved or executed | Execute, using executionToken as your provider idempotency key |
| require_approval | held | Wait for a human decision (see below) |
| block | blocked, denied, timed_out, collision | Do not execute; reasons says why |
score is the risk score as an integer from 0 to 1000 (or null when the action was unevaluable). reasons is a list of short machine-readable strings explaining the decision.
Waiting on a held action
When the verdict is require_approval, the server returns a held ticket immediately and a human decides in the Sevra console or Telegram. Three ways to handle it:
Block until resolved (the default). With no
mode(ormode: "block"),guard()polls the decision endpoint under the hood, using bounded server-side waits of up to 25 seconds per poll, until the hold resolves oroverallTimeoutMselapses (default 900000 ms). No single connection is ever held long; the state is durable server-side. If your client-side deadline passes first,guard()returnsverdict: "block",status: "timed_out".Manual poll. Pass
mode: "poll"andguard()returns the held ticket at once. Poll yourself withsevra.decision(requestId)untilstatusleaves"held". When the hold is approved, the first poll claims the single execution and returnsstatus: "executed"with theexecutionToken; polling again is safe and returns the same token.mode: "webhook"withcallbackUrl. The ticket returns at once and thecallbackUrlis recorded on the request. Callback delivery is not yet active on the server, so today you still learn the outcome by polling; use block or poll mode until delivery ships.
Note that the hold itself expires server-side (per-policy, default 900 seconds); an unresolved hold times out to block. See the timeout section.
Idempotency keys
idempotencyKey is required and must be non-empty. It is your exactly-once anchor:
- Retrying the same key with the same params replays the stored decision verbatim. Safe to retry freely.
- Reusing a key with different params is a collision: the server returns HTTP 409 and the SDK maps it to
verdict: "block",status: "collision".
Use a key derived from the business action, for example refund:<orderId>, so a crashed-and-retried agent cannot double-move money.
Movement fields
amountMinor, currency, and direction describe the money movement and feed reversibility scoring, the case file, and the audit trail. The server validates them at the front door:
amountMinor: a safe non-negative integer, in minor units (cents).currency: an ISO 4217 shaped code (3 letters; normalized to uppercase).amountMinorandcurrencymust be provided together or not at all. Half a movement is a 400.direction:"out"or"in"(optional; anything else is a 400).
Timeouts and fail-closed
Two distinct timeouts:
- Server hold timeout. A held action expires after a per-policy window (matched rule, then protected path, then org default, then the 900 second default; a caller-supplied
timeoutSecondscan only shorten it, never extend, floor 30 seconds). An expired hold becomesstatus: "timed_out", which is a block. - Client overall timeout. In block mode,
overallTimeoutMs(default 900000) bounds how longguard()waits. Past it, the SDK returnsverdict: "block",status: "timed_out"locally.
Fail-closed semantics: if Sevra cannot evaluate an action (control layer unprovisioned, a dependency down, the audit write for an allow fails), the server returns a block, never an allow. If the network fails outright, guard() throws; since you only execute on an allow with a token, a thrown error is a block by construction. There is no path where uncertainty produces an execution.
Shadow mode
An agent not yet marked live runs in shadow mode. On this proactive guard endpoint, shadow is an audit annotation only: the response may carry mode: "shadow", but the real verdict is still enforced. A would-be block is blocked and a would-be hold is held; shadow never downgrades a decision to allow.
Errors and statuses
| Signal | Meaning |
|---|---|
| HTTP 401 | Missing or invalid guard key |
| HTTP 400 | Missing action, params, or idempotencyKey; empty key; invalid movement fields |
| HTTP 409 | Idempotency key reused with different params; SDK returns status: "collision" |
| status: "held" | Awaiting a human decision |
| status: "timed_out" | Hold expired (server) or client deadline passed; treat as block |
| status: "blocked" / "denied" | Terminal block; reasons explains |
The SDK does not throw on HTTP error statuses other than by JSON parsing; it throws only on transport failure. Always gate execution on verdict === "allow" and the presence of executionToken, not on the absence of an error.
HTTP API
The SDK is a thin client over two endpoints, POST /api/app/gate/guard and GET /api/app/gate/requests/{id}/decision. The full language-agnostic reference lives at sevra.dev/docs.
