API reference
Every export of @agentveins/core and @agentveins/adapter-solana.
createGuard
const guard = await createGuard(options: GuardOptions): Promise<Guard>Async, because it replays the audit log at startup to reconstruct spend and frozen state. Invalid configuration throws here: that is the only place AgentVeins throws for policy reasons.
GuardOptions
| Option | Type | Notes |
|---|---|---|
policy | Policy | validated at construction; bad shapes throw |
adapters | WalletAdapter[] | at least one required |
audit | AuditSink | fileAuditSink or memoryAuditSink |
agent | string | recorded on every entry |
logId | string | names this agent's log; a log claiming another id is refused |
signingKey | KeyObject | ed25519 private key; signs entries and the anchor |
verifyingKey | KeyObject | optional; derived from signingKey when omitted |
anchor | AnchorStore | optional but strongly recommended, without it a deleted log looks like a fresh start |
approvals | ApprovalStore | required when policy.approvals is set; createGuard throws without it |
requirePersistedState | boolean | throw if the sink cannot replay |
now | () => Date | injectable clock, for tests |
Guard
interface Guard {
pay(req: PayRequest): Promise<PayResult>;
freeze(): Promise<void>;
unfreeze(): Promise<void>;
state(): SpendState;
flush(): Promise<void>;
}freeze() closes the kill switch in memory before it returns, so every later pay is already blocked even while a payment waits on a slow rail. Only its audit entry is queued behind that payment: flush() waits for that, and you should call it before exiting a process that just froze.
Payments themselves stay strictly serialized, so two concurrent calls can never race the same budget. unfreeze() is queued in full: the switch may snap shut out of turn, never open out of turn.
Policy
Policy is data: JSON-serializable, versionable, diffable.
interface Policy {
budgets: Budget[];
vendors: VendorPolicy;
recipients?: RecipientPolicy;
approvals?: ApprovalPolicy;
velocity?: VelocityRule[];
killSwitch: KillSwitch;
}validatePolicy() rejects unknown periods, duplicate periods, unparseable or negative limits, over-precise amounts, an empty allowlist, a non-boolean kill switch, and a velocity rule with no cap or a window over 24h: all at construction time.
Approvals
A payment strictly greater than the threshold is held until a human authorises those exact terms.
approvals?: { above: string }; // "5.00"; a payment of exactly 5.00 passespay() does not wait. It returns blocked with approval_required and never reaches the rail, so PayResult keeps its three states and one held payment cannot stall the others — payments are serialized, and anything that waited inside pay() would hold that queue for the length of a human's attention.
interface ApprovalKey { agent: string; vendorNormalized: string; amountMinor: bigint }
interface Approval extends ApprovalKey { id: string; expiresAt: string; usedAt: string | null }
interface ApprovalStore {
grant(input: ApprovalKey & { expiresAt: string }): Promise<Approval>;
find(key: ApprovalKey): Promise<Approval | null>;
consume(id: string): Promise<void>; // atomic; throws if already spent
}memoryApprovalStore ships from the main entry, fileApprovalStore from @agentveins/core/fs.
An approval authorises one agent, one vendor, one exact amount, once, until it expires. Two grants on the same terms are two authorisations, not one reusable one. It is consumed before the rail is called, so a rail failure burns it and the human is asked again — the same direction the guard already rounds for a settlement it cannot confirm.
What AgentVeins does not do
It never asks anyone. The guard records that a payment needs a person and enforces the answer; routing the request belongs wherever your organisation already makes decisions. Everything needed is in the denial — pay() returns the violation, and the same attempt lands in the audit log with the agent, vendor, amount and reason, so the log doubles as the queue of what is waiting. @agentveins/cli is a worked example of that routing.
| Situation | Result |
|---|---|
| No approval, or expired, or already spent | blocked / approval_required |
| The store cannot be read, or the approval cannot be claimed | blocked / approval_unavailable. No latch — payments below the threshold keep settling |
policy.approvals set with no store | throws at createGuard |
| Frozen while the gate was waiting on the store | blocked / kill_switch; an approval already spent stays spent |
Velocity
The daily budget bounds the damage. Velocity bounds the pace, so a person has time to notice before the budget is the thing that saved them.
velocity?: VelocityRule[];
interface VelocityRule {
window: string; // "30s", "10m", "2h" — capped at "24h"
maxPayments?: number; // more than this many in the window is refused
maxAmount?: string; // more than this much in the window is refused
}An array, because "at most 20 payments per 10 minutes and at most 5.00 per hour" is one
policy rather than a choice. Every rule is checked; the first exceeded returns
velocity_exceeded, and the message names the window and the cap.
Caps are inclusive and the candidate payment counts, exactly as the budget behaves:
maxPayments: 10 permits the tenth and refuses the eleventh, and maxAmount: "5.00" with
4.99 already in the window refuses a 0.02 payment.
Only money that moved counts. Refusals never extend a window. Counting them would let the guard's own blocks trip further blocks — a spiral where every retry pushes recovery further away — so a hammering agent stays refused for the window's length and no longer.
The window is rebuilt from the log, like the budget: the guard replays the settled and uncertain payments of the last 24 hours at startup, so restarting an agent cannot reset its velocity any more than it can reset its spend.
The one refusal that clears on its own
Every other block needs something to change — a smaller amount, a different vendor, a person's approval, an operator lifting the switch. A velocity block needs only time. That makes it the one case where an agent should wait and retry unchanged, and the MCP guidance and the plugin skill both say so rather than leaving the agent to guess.
Velocity runs after the budget, which is deliberate: a payment over the daily limit reports
budget_exceeded, the permanent refusal, rather than the one that reads as "wait a moment".
A window longer than 24h is refused at construction. A velocity rule measured in days is a
budget wearing a costume, and the retained window is bounded at 24 hours, so a rule the state
cannot answer must not validate.
Results
type ViolationCode =
| "kill_switch"
| "vendor_not_allowed"
| "budget_exceeded"
| "velocity_exceeded"
| "approval_required"
| "approval_unavailable"
| "invalid_request"
| "audit_unavailable";
type PaymentErrorCode =
| "adapter_error"
| "price_mismatch"
| "recipient_not_allowed"
| "insufficient_funds"
| "timeout";price_mismatch and recipient_not_allowed mean the vendor asked for something you did not approve: retrying hits the same refusal.
Audit
verifyAuditLog(entries, publicKey, options?): Promise<VerifyResult>
sealAnchor(input, privateKey): Anchor
verifyAnchor(anchor, publicKey): boolean
fileAuditSink(path) · memoryAuditSink(seed?)
fileAnchorStore(path) · memoryAnchorStore(seed?)verifyAuditLog takes optional logId and anchor: supply both to detect substitution and truncation, not just edits.
Money
parseAmount("25.00"): bigint // → 25_000_000n
formatAmount(25_000_000n): string
USDC_DECIMALS: 6solanaAdapter
solanaAdapter({
keypair, // webcrypto.CryptoKeyPair: devnet only
rpcUrl,
mode: "direct" | "x402",
usdcMint?, // defaults to the devnet USDC mint
confirmTimeoutMs?, // default 90_000
confirmMaxAttempts?, // default 300
timeoutMs?, // per-fetch timeout in x402 mode
})direct builds an SPL USDC transfer, signs it, submits it, then polls getSignatureStatuses bounded by the transaction's lastValidBlockHeight and by the wall clock and attempt ceilings above.
x402 requests the resource, receives a 402 quote, and attaches a signed transaction in an X-PAYMENT header: built the way the exact SVM scheme requires, with the facilitator as fee payer and only partial signing.
x402 status
x402 mode settles real USDC on devnet. The facilitator is x402's own reference implementation run in process, not a hosted third party, so settlement against someone else's facilitator remains untested. The agent signs a transfer it cannot broadcast and the facilitator pays the fee and submits it: on a settled transaction the agent's SOL balance is unchanged, which is the fee-payer split the scheme depends on.
Devnet only. No mainnet configuration exists in either package.