@three-ws/agent-guards
v0.1.0
Published
Safety rails for autonomous agents — per-agent spend policies and trade guards that cap what an agent can spend or trade before a transaction is signed. The three.ws agent custody SDK.
Maintainers
Readme
@three-ws/agent-guardsis the official client for the three.ws agent custody guardrails — the same policy engine that governs every outbound movement of a custodial agent wallet on the platform. It lets an owner set hard spend and trade ceilings on an agent, and lets a caller pre-check a proposed buy/sell against those ceilings before signing. One policy is enforced uniformly across all four custody paths — discretionary trade, autonomous snipe, x402 pay, and owner withdraw — so an agent (or a stolen session token) can never drain a wallet past the leash its owner set. It wraps the live/api/agents/:id/tradeand/api/agents/:id/trade/limitsendpoints. All trading is SOL-quoted on Solana; the only coin three.ws promotes is $THREE.
Why
An autonomous agent that can sign transactions is a loaded gun pointed at a wallet. The moment you hand an LLM a key, you need an answer to one question on every action: is this allowed? Hand-rolling that answer means re-deriving the same math in four places — and the day the trade path and the snipe path disagree on the daily cap is the day a wallet gets drained.
three.ws solves it once, server-side, as a single policy:
- Two-tier ceilings. A per-transaction cap stops one oversized trade; a rolling 24-hour cap stops a thousand small ones. Both are enforced atomically under a per-agent lock, so concurrent spends can't race past the limit.
- A real kill switch. Flip
kill_switch(trades) orfrozen(all autonomous paths) and every outbound action is rejected immediately — but the owner's own withdraw stays open, so a freeze locks down a misbehaving agent without trapping its funds. - Circuit breakers, not just caps. A price-impact breaker and a slippage ceiling reject trades that would execute at a bad price, independent of size.
- Owner-set, opt-in. An unset ceiling means "no global cap" — existing flows keep working. The moment an owner tightens the policy it becomes a hard limit applied everywhere.
This SDK is the programmatic twin of the Limits & Safety surface in the three.ws agent dashboard — the same policy, exposed as plain functions instead of a settings panel.
Install
npm install @three-ws/agent-guardsZero runtime dependencies. Works in Node 18+ and the browser (uses fetch).
Setting or reading a policy is an owner-only action and requires the agent owner's
session cookie or bearer token.
Quick start
Read an agent's current trade policy, then set a leash:
import { guards } from '@three-ws/agent-guards';
const a = guards('agent_abc123', { token: process.env.THREE_WS_TOKEN });
// Set the rails: 0.5 SOL per trade, 2 SOL/day, reject >10% price impact.
await a.setTradeLimits({
per_trade_sol: 0.5,
daily_budget_sol: 2,
max_price_impact_pct: 10,
max_slippage_bps: 500,
});Pre-check a proposed buy before you commit to signing it. simulate: true runs the
full quote → guard pipeline and returns the decision without moving funds:
const decision = await a.checkTrade({ side: 'buy', mint, amount: 0.3 });
if (!decision.allowed) {
console.log(decision.reason); // e.g. 'per_trade_cap'
console.log(decision.message); // human-readable, actionable
} else {
await a.trade({ side: 'buy', mint, amount: 0.3 }); // really executes
}The kill switch, in one line:
await a.setTradeLimits({ kill_switch: true }); // every discretionary trade now rejectedAPI
guards(agentId, options?) → AgentGuards
Bind the client to one agent. options.token is the owner's bearer token, or pass
options.cookie for a session. options.baseUrl overrides the default
https://three.ws.
.getTradeLimits() → Promise<TradeLimits & { defaults }>
GET /api/agents/:id/trade/limits. Returns the effective discretionary-trade
policy plus the platform defaults. Lamport/SOL-denominated knobs that govern the
agent's own buys:
| Field | Type | Default | Meaning |
|---|---|---|---|
| per_trade_sol | number \| null | null | Max SOL on any single buy. null = uncapped. |
| daily_budget_sol | number \| null | null | Rolling-24h SOL buy budget across trade and snipe. null = uncapped. |
| max_price_impact_pct | number | 15 | Circuit breaker — reject a trade over this price impact. |
| max_slippage_bps | number | 1000 | Ceiling on client-supplied slippage (basis points). |
| max_concurrent | number \| null | null | Max open discretionary positions. null = unlimited. |
| kill_switch | boolean | false | When true, every discretionary trade is rejected. |
.setTradeLimits(patch) → Promise<TradeLimits>
PUT /api/agents/:id/trade/limits. Owner-only, CSRF-gated (bearer callers exempt).
A patch — only the keys you pass change; the rest are preserved. Values are
normalized and bounded server-side (max_price_impact_pct clamps to 0–100,
max_slippage_bps to 0–10000, max_concurrent to 1–10000). Returns the new policy.
.checkTrade({ side, mint, amount, ... }) → Promise<Decision>
Pre-flight a trade against every guard without moving funds — wraps the live
POST /api/agents/:id/trade with simulate: true. Returns:
| Field | Type | Notes |
|---|---|---|
| allowed | boolean | true if the trade clears every guard. |
| reason | string \| null | Machine code when blocked — see reasons. |
| message | string \| null | Plain-language, actionable explanation. |
| detail | object | The numbers behind the decision (amounts, caps, spent-so-far). |
.trade({ side, mint, amount, ... }) → Promise<TradeResult>
POST /api/agents/:id/trade. Executes the real trade — runs the identical
quote → guard → build → custody-claim → sign → confirm pipeline, so it can never
exceed the policy checkTrade reported on.
Request body
| Field | Type | Notes |
|---|---|---|
| side | 'buy' \| 'sell' | Buys spend SOL; sells move SOL inward. Required. |
| mint | string | Base58 Solana mint of the coin to trade. Required. |
| amount | number \| 'max' | Buy: SOL to spend. Sell: tokens to sell, or 'max'. |
| slippageBps | number | Slippage in bps. Clamped to max_slippage_bps. |
| network | string | mainnet (default) or devnet. |
| simulate | boolean | Dry run — never signs or records. checkTrade sets this for you. |
| idempotency_key | string | De-dupe retries of the same trade. |
Buys are gated on the per-trade cap, daily SOL budget, concurrency ceiling, the cross-path USD ceiling, and a SOL fee/rent headroom floor (~0.003 SOL kept above the spend). Sells only move SOL inward, so they skip the spend caps but still honor the kill switch, the price-impact breaker, and the headroom floor.
.getSpendLimits() / .setSpendLimits(patch)
The cross-path USD spend policy (distinct from the SOL trade limits above), stored
at meta.spend_limits and enforced uniformly across trade, snipe, x402 pay, and
withdraw. Patch semantics are identical to the trade limits.
| Field | Type | Default | Meaning |
|---|---|---|---|
| daily_usd | number \| null | null | Rolling-24h USD-equivalent outflow ceiling. |
| per_tx_usd | number \| null | null | Max USD-equivalent for any single outbound tx. |
| withdraw_allowlist | string[] | [] | If non-empty, withdraws may only target these addresses (max 50, validated base58). |
| frozen | boolean | false | Kill switch for all autonomous paths. Owner withdraw stays open. |
How it works
Every outbound movement of an agent wallet runs through one policy. The guards are pure, synchronous predicates — the server fetches the live numbers (open count, 24h spend, wallet balance, quote) and hands them in, so the same comparison backs both the discretionary trade endpoint and the autonomous sniper. A cap can never drift between paths.
proposed trade (side, mint, amount)
│
▼
quote the venue ──► price impact, expected out, USD value
│
▼
┌──────────────────────── guard pipeline ────────────────────────┐
│ kill switch ──► price-impact breaker ──► per-trade SOL cap │
│ ──► concurrency ceiling ──► daily SOL budget (rolling 24h)│
│ ──► cross-path USD ceiling ──► SOL fee/rent headroom │
└─────────────────────────────┬───────────────────────────────────┘
clear? ──no──► 4xx { code, message, detail } (never a 500)
│ yes
▼
reserve in the custody ledger ──► sign ──► confirm ──► finalizeThe two caps that matter most — the rolling daily SOL budget and the daily USD ceiling — are enforced atomically under a per-agent advisory lock. The check and the ledger reservation happen in a single statement, so K concurrent spends can never all read the same stale 24h total and all pass, turning an $X/day cap into $X·K. A reservation that never settles is released, so a failed attempt does not permanently consume the agent's budget.
Guard reasons
A blocked trade is always a structured 4xx with a machine code and a plain-language
message — never a thrown 500. checkTrade surfaces these on decision.reason;
trade rejects with the same shape.
| reason | HTTP | Meaning | Recovery |
|---|---|---|---|
| kill_switch | 403 | Discretionary trading is paused for this agent. | Re-enable trading (kill_switch: false). |
| wallet_frozen | 403 | The wallet is frozen; all autonomous spending is paused. | Unfreeze (frozen: false). Owner withdraw still works. |
| per_trade_cap | 422 | The buy exceeds per_trade_sol. | Lower the amount or raise the cap. |
| daily_budget | 422 | The buy would exceed the rolling-24h SOL budget. | Wait for the window to roll, or raise the budget. |
| per_tx_exceeded | 403 | The tx exceeds per_tx_usd. | Lower the amount or raise the per-tx USD limit. |
| daily_exceeded | 403 | The tx would exceed daily_usd. | Wait, or raise the daily USD limit. |
| max_positions | 409 | Open trades already at max_concurrent. | Close a position before opening another. |
| price_impact | 422 | Price impact above max_price_impact_pct. | Lower the trade size or raise the breaker. |
| insufficient_sol | 400 | Wallet can't cover the spend plus fee/rent headroom. | Fund the wallet and retry. |
| destination_not_allowed | 403 | Withdraw target not on withdraw_allowlist. | Add the address, or send to an allowed one. |
detail carries the raw numbers — e.g. per_trade_cap returns
{ amount_lamports, cap_lamports }, daily_budget returns
{ spent_lamports, amount_lamports, budget_lamports } — so a UI can render the
exact overage without re-deriving it.
Examples
Provision a new agent with conservative rails before handing it a key:
const a = guards(agentId, { token });
await a.setTradeLimits({
per_trade_sol: 0.25,
daily_budget_sol: 1,
max_concurrent: 3,
max_price_impact_pct: 8,
});
await a.setSpendLimits({ daily_usd: 100, per_tx_usd: 25 });Gate an LLM tool call — let the model propose, but never let it sign past the leash:
async function agentBuy({ mint, sol }) {
const check = await a.checkTrade({ side: 'buy', mint, amount: sol });
if (!check.allowed) {
return { ok: false, reason: check.reason, message: check.message };
}
return a.trade({ side: 'buy', mint, amount: sol, idempotency_key: crypto.randomUUID() });
}Emergency stop — freeze every autonomous path while you investigate, without locking yourself out of the funds:
await a.setSpendLimits({ frozen: true }); // trades, snipes, x402 all rejected; withdraw stays openRead the policy under the hood — the raw HTTP this SDK wraps:
const res = await fetch(`https://three.ws/api/agents/${agentId}/trade/limits`, {
headers: { authorization: `Bearer ${token}` },
});
const { data } = await res.json(); // { limits, defaults }Related
@three-ws/x402-fetch— auto-pay x402 endpoints; the same spend policy governs every payment.@three-ws/forge— give your guarded agent a 3D body.
