@axon-trading/sdk
v1.2.4
Published
Axon SDK — typed TypeScript client for the local Axon trading daemon. Drive Hyperliquid perp trades, wallets, and policy from AI agents (Claude Code, Claude Desktop, Cursor, Codex, Cline, Continue, Zed, Windsurf, OpenClaw) or any Node.js script. Non-custo
Maintainers
Keywords
Readme
@axon-trading/sdk
🔌 Typed TypeScript client for the Axon trading daemon. Drive Hyperliquid perp trades, wallets, and policy from AI agents (Claude Code, Claude Desktop, Cursor, Codex, Cline, Continue, Zed, Windsurf, OpenClaw) or any Node.js script. Every function is fully typed, every error has a machine-readable rejection code, and the public surface stays stable across versions.
Lives in browser (the Axon dashboard uses it) and any Node-compatible MCP runtime. Fully typed. ~30 KB unpacked. Zero hidden network calls — only talks to your local daemon at
http://127.0.0.1:47890. Non-custodial: the SDK never sees a private key. Signing happens inside the daemon.
✨ What's new in 1.0.0
- 📦 Strategy types —
StrategyManifest,CatalogStrategy,StrategyPricing,StrategyAuthorexposed for marketplace integration. Use these to build catalog browsers, strategy editors, or signature verifiers in your own code. - 🛒 Marketplace client methods —
signMarketplaceChallenge({ kind, … })for the daemon-side EIP-712 envelope signing (auth challenges, votes, uploads). Wraps the pending-action ceremony so your code just awaits the signed payload. - 💰 x402 / EIP-3009 types —
X402Receipt, payment intent shapes for paid-strategy purchase flows. Phase 9.0/9.1 hardening enforces strict validation in the daemon before any signature emits — you get typed errors for every mismatch path. - 🔄 Health surface gains
updateAvailable— your client can prompt users to upgrade when an outdated daemon version detects a new release on npm. - 🛡️ Backwards-compatible — every public type from
0.x/1.0.0-rc.xis preserved. Major version bump signals that we're committing to public-API stability going forward.
Full changelog: CHANGELOG.md.
Works With
| Runtime / use case | How |
|---|---|
| Claude Code | Use via @axon-trading/mcp (auto-installed) — or import the SDK directly in a custom script |
| Claude Desktop | Same — the MCP adapter wraps this SDK |
| Cursor | import { createClientFromEnv } from "@axon-trading/sdk" in a script the agent can run |
| Codex | Same — full SDK works inside Codex's Node sandbox |
| Cline (VS Code) | Same — drop the SDK into any project Cline can edit |
| Continue.dev | Same |
| Zed | Same |
| Windsurf | Same |
| OpenClaw | Adapter at packages/adapters/openclaw/ wraps this SDK |
| Custom AI agent | import { createClientFromEnv } from "@axon-trading/sdk" |
| LangChain tool | Wrap SDK methods in Tool instances |
| Backtesting harness | Read-only methods (getPositions, getOhlcv, getTradeHistory) |
| Dashboard / UI | Browser-safe; auto-binds globalThis.fetch |
| MCP adapter | Already used internally by @axon-trading/mcp |
| Server-side automations | import { AxonClient } from "@axon-trading/sdk" |
Built for: Claude Code • Claude Desktop • Codex • Cursor • Continue • Cline • Zed • Windsurf • OpenClaw • LangChain • LangGraph • AutoGPT • CrewAI • custom Node/browser apps
Perfect For
- 🤖 AI agent toolchains — exposes every Axon capability as a fully-typed function. Branch on rejection codes, never parse free-form messages.
- 📊 Backtesting + research — pure read methods (
getOhlcv,getTradeHistory,getPositions) with TypeScript types. - 🌐 Dashboards and admin UIs — browser-safe; the Axon dashboard itself uses this SDK.
- 🔄 Trading automations — webhooks, cron jobs, or arbitrage loops that drive the daemon programmatically.
- 🧪 Integration tests — spin up a demo-mode daemon and use the SDK to verify your agent logic before going live.
Table of Contents
- Features
- Prerequisites
- Installation
- Quick Start
- Authentication
- Trading API
- Read API
- Wallet Management
- Pending Actions (passphrase via browser)
- Operations
- Error Handling
- Type Definitions
- TypeScript Best Practices
- Browser Usage
- Advanced Topics
- Troubleshooting
- FAQ
- License
Features
- 🎯 Fully typed — every method's params + return shapes are TypeScript-typed
- 🔌 Auto-discovery —
createClientFromEnv()reads~/.axon/agent.envautomatically - 🔁 Retry-aware — built-in retry on transient daemon-restart errors
- 📜 Structured rejections — every venue/policy rejection is a typed
RejectionResultwith a machine-readablecode - 🌐 Browser-safe — auto-binds
fetchtoglobalThisto dodge theWindow.fetchIllegal invocationtrap - 🔢 Helper builders —
openPerp({...}),closePerp({...}), etc. produce correctly-shaped intents - 📊 SSE streaming —
streamEvents()for live audit-log + position updates - 🧪 Test-friendly — accepts a custom
fetchFnso tests can mock without monkey-patching globals
Prerequisites
- Node.js ≥ 20 (for SDK consumers in Node) — or any modern browser
- The Axon daemon running locally —
npm install -g @axon-trading/cli && axon - TypeScript ≥ 5.0 (recommended; the SDK ships
.d.tss)
Installation
npm install @axon-trading/sdk
# OR
pnpm add @axon-trading/sdk
# OR
yarn add @axon-trading/sdkFor the pre-release line:
npm install @axon-trading/sdk@nextQuick Start
import { createClientFromEnv, openPerp } from "@axon-trading/sdk";
// Reads AXON_URL + AXON_TOKEN from env / ~/.axon/agent.env
const client = await createClientFromEnv({ retry: true });
const result = await client.placeIntent(
openPerp({
venue: "hyperliquid",
symbol: "BTC",
side: "long",
leverage: 5,
sizeUsd: 100,
}),
);
if (!result.ok) {
// ALWAYS branch on result.code — never parse result.message
console.error(`Rejected: ${result.code}`);
if (result.code === "HL_INSUFFICIENT_MARGIN") {
// Reduce size or deposit more
}
return;
}
console.log(`Filled ${result.filled.size} ${result.filled.symbol} @ ${result.filled.avgPrice}`);Authentication
Axon's local API uses bearer-token auth. Tokens rotate on every daemon boot, so the SDK reads them fresh.
Auto-discovery (recommended)
import { createClientFromEnv } from "@axon-trading/sdk";
// Resolution order:
// 1. process.env.AXON_URL + AXON_TOKEN
// 2. ~/.axon/agent.env (auto-written by daemon every boot)
// 3. Legacy: ~/.axon/session.token + AXON_URL fallback
const client = await createClientFromEnv({ retry: true });Manual construction
import { AxonClient } from "@axon-trading/sdk";
const client = new AxonClient({
baseUrl: process.env.AXON_URL ?? "http://127.0.0.1:47890",
token: process.env.AXON_TOKEN!,
userAgent: "my-agent/1.0", // identifies you in the daemon's audit log
timeoutMs: 15_000, // default: 15s
});Container / serverless
Pass env explicitly — auto-discovery requires filesystem access to ~/.axon/agent.env.
const client = new AxonClient({
baseUrl: process.env.AXON_URL!,
token: process.env.AXON_TOKEN!,
});Trading API
placeIntent(intent: Intent): Promise<IntentResult>
The single entry point for trading. Pass any intent built from the helpers below.
Intent helpers
import {
openPerp,
closePerp,
cancelOrder,
updateLeverage,
} from "@axon-trading/sdk";openPerp(args)
const intent = openPerp({
venue: "hyperliquid", // "hyperliquid" | "lighter"
symbol: "BTC", // venue's canonical perp symbol
side: "long", // "long" | "short"
leverage: 5, // 1..venue-max-leverage
sizeUsd: 100, // notional size in USD
reduceOnly: false, // optional: only close existing position
clientId: "my-bot-001", // optional: idempotency key (16 bytes max for HL)
limitPrice: 67_500.0, // optional: limit order; omit for market
});
const result = await client.placeIntent(intent);
// IntentResult success shape:
// {
// ok: true,
// intentId: "intent_<uuid>",
// venue: "hyperliquid",
// filled: {
// symbol: "BTC",
// side: "long",
// size: 0.0015, // base units
// avgPrice: 67_492.5,
// fee: 0.05, // USD
// },
// txProofUrl: "https://app.hyperliquid.xyz/orders/...",
// }closePerp(args)
const intent = closePerp({
venue: "hyperliquid",
symbol: "ETH",
sizeBase: 0.5, // close 0.5 ETH (use the position's size for full close)
// OR: sizePct: 100 for full close
clientId: "close-eth-001",
});
const result = await client.placeIntent(intent);cancelOrder(args)
const intent = cancelOrder({
venue: "hyperliquid",
symbol: "BTC",
orderId: "0x...", // from a previous open's response, or `getOrders()`
});updateLeverage(args)
const intent = updateLeverage({
venue: "hyperliquid",
symbol: "ETH",
leverage: 10,
isCross: false, // optional; default false (isolated margin)
});Read API
All read methods return typed results. No signing, no side effects, no policy gates.
client.getBalance(): Promise<Balance>
// { onChainUsdc, hyperliquidMargin, ethGas, totalEquityUsd }
client.getPositions(): Promise<Position[]>
// [{ venue, symbol, side, size, entryPrice, markPrice, unrealizedPnl, leverage, liquidation, ... }]
client.getMarkets(venue?: "hyperliquid" | "lighter"): Promise<Market[]>
// [{ venue, symbol, tickSize, minSizeUsd, maxLeverage, ... }]
client.getTicker(venue, symbol): Promise<Ticker>
// { mark, bid, ask, last, volume24h, fundingRate, ... }
client.getOhlcv(venue, symbol, interval, limit?): Promise<Candle[]>
// [{ ts, open, high, low, close, volume }, ...]
// interval: "1m" | "5m" | "15m" | "1h" | "4h" | "1d"
client.getTradeHistory(opts?: { limit?, symbol?, status?, venue? }): Promise<TradeRow[]>
// Normalized fills + open orders across venues
client.getAuditEvents(opts?: { limit?, kinds?, agentId?, refId? }): Promise<AuditEvents>
// Hash-chained audit log (every intent, rejection, signed action)
client.inspectBridgeBalance(opts?: { ownerAddress? }): Promise<BridgeStatus>
// Cross-references on-chain USDC vs HL credited margin (debug stuck deposits)
client.runDoctor(): Promise<DoctorReport>
// Full system health checkStreaming
const stream = client.streamEvents({ kinds: ["intent_placed", "intent_filled"] });
for await (const event of stream) {
console.log(event.kind, event.payload);
}
// Send AbortController.signal to stopWallet Management
client.listWallets(opts?: { includeHealth? }): Promise<WalletListResult>
// { wallets: [{ id, label, address, kind, isActive, status }], activeWalletId }
client.setActiveWallet(walletId: string): Promise<{ ok: true }>
client.renameWallet(walletId: string, label: string): Promise<{ ok: true }>
client.listAgentBindings(): Promise<AgentBindingsResult>
// { bindings: [{ clientId, walletId, walletLabel }], knownClientIds, defaultWalletId }
client.setAgentBinding({ clientId, walletId }): Promise<{ ok: true }>
client.setDefaultBinding(walletId: string): Promise<{ ok: true }>Pending Actions (passphrase via browser)
Some operations require the user's master-EOA passphrase. The SDK never collects passphrases. Instead, these methods return a PendingActionResult containing a confirmationUrl — display it to the user; they enter their passphrase in a browser form; you then poll for the result.
client.generateWalletPending(opts?: { label? }): Promise<PendingActionResult>
client.importWalletPending({ mnemonic, label? }): Promise<PendingActionResult>
client.importWalletFromPkPending({ privateKey, label? }): Promise<PendingActionResult>
client.deleteWalletPending(walletId: string): Promise<PendingActionResult>
client.transferUsdcPending({ from, to, amount }): Promise<PendingActionResult>
client.hlDepositPending({ amount }): Promise<PendingActionResult>
client.hlSpotToPerpPending({ amount, reverse? }): Promise<PendingActionResult>
client.approveBuilderPending(opts?: { maxFeeRate? }): Promise<PendingActionResult>
client.killSwitchOffPending(): Promise<PendingActionResult>
client.setModePending(mode: "demo" | "testnet" | "live"): Promise<PendingActionResult>Polling pattern
const pending = await client.hlDepositPending({ amount: 20 });
console.log(`Open this URL to confirm: ${pending.confirmationUrl}`);
// User opens URL, enters passphrase, submits
const result = await client.pollPendingAction(pending.token, { timeoutMs: 300_000 });
if (!result.ok) {
if (result.code === "BAD_PASSPHRASE") {
// Tell user their passphrase was wrong; they can retry on the same page
} else if (result.code === "EXPIRED") {
// Token expired (5min default) — re-call hlDepositPending() to get a fresh one
}
}The PendingActionResult shape:
{
pending: true,
token: "pa_<32hex>",
confirmationUrl: "http://127.0.0.1:47890/confirm/pa_<32hex>",
pollUrl: "/v1/pending/pa_<32hex>",
expiresAt: 1700000300000,
}Operations
client.killSwitchOn(): Promise<{ ok: true }>
// Agents CAN arm — safety always wins. Cancels every open order, halts new ones.
client.getMode(): Promise<{ running: ModeId, latched: ModeId }>
// running = current process; latched = what next restart will boot into
client.auditVerify(opts?: { sinceMs? }): Promise<AuditVerifyResult>
// Verify the hash chain. Returns { ok: true, events: 1234 } or throws AuditTamperError.
client.listAnchors(limit?: number): Promise<AnchorRow[]>
// On-chain settlement anchors (weekly Merkle-root summaries, hashes anchorable on-chain)
client.settleNow(): Promise<{ ok: boolean, ranSettlement: boolean, txHash?: string }>
// Force a settlement cycle (normally weekly cron-driven)
client.getPolicy(): Promise<PolicyShape>
// Read current policy. AGENTS CANNOT MODIFY — read-only.Error Handling
Every method either:
- Returns a typed success result, OR
- Returns a
RejectionResultwith a structuredcode:
type RejectionResult = {
ok: false;
code: RejectionCode; // e.g. "HL_INSUFFICIENT_MARGIN"
message: string; // human-readable
hint?: string; // optional recovery hint
field?: string; // optional field name that was the problem
};Always branch on code, NEVER parse message. Codes are stable; messages are not.
Common patterns
const result = await client.placeIntent(openPerp({ ... }));
if (!result.ok) {
switch (result.code) {
case "HL_INSUFFICIENT_MARGIN":
// Reduce size or deposit more
break;
case "HL_BUILDER_FEE_NOT_APPROVED":
// Call approveBuilderPending() first
break;
case "HL_AGENT_NOT_APPROVED":
// User: run `axon hl revoke-agent`, then retry
break;
case "KEYSTORE_LOCKED":
// Tell user to unlock via dashboard
break;
case "WALLET_BINDING_MISMATCH":
// Show the bound wallet vs active wallet; offer to rebind
break;
case "POLICY_PER_AGENT_CAP":
// Daily notional cap reached — wait or raise cap
break;
case "KILL_SWITCH_DENIED":
// Tell user to disarm in dashboard
break;
default:
console.error(`Unhandled rejection: ${result.code} — ${result.message}`);
}
}Full code reference: docs/REJECTION_CODES.md.
Network-level errors throw AxonError
import { AxonError } from "@axon-trading/sdk";
try {
await client.getBalance();
} catch (err) {
if (err instanceof AxonError) {
// Daemon unreachable, timeout, malformed response, etc.
// Retry with backoff, OR tell user the daemon isn't running.
console.error("Daemon not reachable:", err.message);
} else {
throw err;
}
}Retry pattern
async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts: number = 3,
): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (!(err instanceof AxonError) || attempt === maxAttempts) throw err;
await new Promise((r) => setTimeout(r, 1000 * attempt));
}
}
throw new Error("unreachable");
}
const balance = await withRetry(() => client.getBalance());The SDK already does this when constructed via createClientFromEnv({ retry: true }).
Type Definitions
import type {
Intent,
IntentResult,
Balance,
Position,
Market,
Ticker,
Candle,
Order,
Fill,
TradeRow,
AuditEvent,
AuditEvents,
PendingActionResult,
RejectionResult,
RejectionCode,
WalletKind,
WalletRow,
WalletListResult,
AgentBindingsResult,
PolicyShape,
ModeId,
StreamChannel,
StreamEvent,
} from "@axon-trading/sdk";All exported. See dist/types.d.ts in the package for the canonical definitions.
TypeScript Best Practices
Type-narrow on result.ok
const result = await client.placeIntent(openPerp({ ... }));
if (result.ok) {
// TypeScript knows result.filled is defined here
const avgPrice: number = result.filled.avgPrice;
} else {
// TypeScript knows result.code is a RejectionCode union here
const code: RejectionCode = result.code;
}Custom type guards
import { IntentResult, RejectionResult } from "@axon-trading/sdk";
function isRejection(result: IntentResult): result is RejectionResult {
return result.ok === false;
}
const result = await client.placeIntent(intent);
if (isRejection(result)) {
// ...
}Strict policy on rejection codes
import { RejectionCode } from "@axon-trading/sdk";
// Compile-time-checked exhaustive switch:
function explainRejection(code: RejectionCode): string {
switch (code) {
case "HL_INSUFFICIENT_MARGIN": return "Reduce size or deposit more.";
case "HL_TICK_SIZE": return "Round price to the venue's tick.";
case "KEYSTORE_LOCKED": return "Unlock the keystore in the dashboard.";
// ... add cases. TypeScript will warn about unhandled codes when the
// RejectionCode union grows in a future SDK version.
default: {
const _exhaustive: never = code;
return `Unknown rejection: ${code}`;
}
}
}Browser Usage
The SDK is browser-safe — the Axon dashboard itself uses it. Two notes:
- The SDK auto-binds
globalThis.fetchtowindowto avoid theTypeError: Illegal invocationtrap (Window.fetch needswindowas receiver). - Browsers strip the standard
User-Agentheader (per Fetch spec); the SDK duplicates it asx-axon-clientso the daemon can still classify requests.
// In a Vite / Next / etc. browser app:
import { AxonClient } from "@axon-trading/sdk";
const client = new AxonClient({
baseUrl: "http://127.0.0.1:47890",
token: localStorage.getItem("axon-token") ?? "",
});
const positions = await client.getPositions();For dashboard-style apps, prefer the SDK's React-Query helpers (no separate package — just standard useQuery({ queryFn: () => client.getBalance() })).
Advanced Topics
Custom fetchFn (testing)
import { AxonClient } from "@axon-trading/sdk";
const calls: any[] = [];
const fakeFetch: typeof fetch = async (url, init) => {
calls.push({ url, init });
return new Response(JSON.stringify({ ok: true, version: "test" }), {
headers: { "content-type": "application/json" },
});
};
const client = new AxonClient({
baseUrl: "http://127.0.0.1:1",
token: "test-token",
fetchFn: fakeFetch,
});
await client.getHealth();
console.log(calls[0].url); // http://127.0.0.1:1/v1/healthCustom user-agent
const client = new AxonClient({
baseUrl: "http://127.0.0.1:47890",
token: TOKEN,
userAgent: "my-research-bot/2.1", // shown in /v1/connections/status + audit log
});When unset, defaults to @axon-trading/sdk/<version>. Setting a custom UA helps the user (a) see your agent in the dashboard's Agents tab, (b) bind it to a specific wallet, (c) trace its calls in the audit log.
Custom timeout
const client = new AxonClient({
baseUrl: "...",
token: "...",
timeoutMs: 60_000, // default 15s; bump for slow venue calls
});Worker-based polling
For long-running bots, use SSE streaming instead of polling:
const stream = client.streamEvents({ kinds: ["intent_filled", "balance_changed"] });
for await (const event of stream) {
if (event.kind === "intent_filled") {
// Update internal state, send Discord notification, etc.
}
}Troubleshooting
AxonError(DAEMON_UNREACHABLE) (rc.5+)
The SDK's HTTP request hit a closed port — daemon is configured but not running. The error includes details.fix carrying the literal command to relay:
try {
await client.getBalance();
} catch (err) {
if (err instanceof AxonError && err.code === "DAEMON_UNREACHABLE") {
// err.details.fix === "Run `axon` in a terminal to start the daemon. ..."
console.error("Tell the user:", err.details?.fix);
}
}In practice: open a terminal and run axon. If you don't have the CLI installed:
npm install -g @axon-trading/cli@next
axonAxonError(DAEMON_NOT_INSTALLED) (rc.5+)
createClientFromEnv() couldn't find credentials AND no axon binary on PATH — the user has never installed Axon. The error's details.state distinguishes:
state: "no-cli"→ install + start:npm install -g @axon-trading/cli@next && axonstate: "cli-no-creds"→ CLI is installed; just runaxonstate: "cli-creds-stale"→ restart withaxonto regenerateagent.env
err.details.fix carries the right command for each state — agents should relay it verbatim.
Legacy AxonError(NETWORK)
Generic fetch failure not recognized by fromFetchFailure. Pre-rc.5 SDK consumers see this for daemon-down scenarios; rc.5+ surfaces DAEMON_UNREACHABLE instead. If you're catching NETWORK, check err.details?.cause for hints — but prefer branching on DAEMON_UNREACHABLE going forward.
UNAUTHORIZED after daemon restart
The bearer token rotated. The SDK auto-retries when constructed via createClientFromEnv({ retry: true }). If you've cached the token manually, re-read ~/.axon/agent.env or restart your client.
KEYSTORE_LOCKED on every call
The daemon is running but locked. Tell the user to open the dashboard at http://127.0.0.1:47890 and enter their passphrase. Agents cannot unlock — by design.
Browser: TypeError: Illegal invocation
You're constructing the client without the auto-bind. Use either createClientFromEnv() OR construct via new AxonClient({ ... }) without overriding fetchFn — the constructor handles the bind for you.
If you must pass a custom fetchFn in a browser:
const client = new AxonClient({
baseUrl: "...",
token: "...",
fetchFn: window.fetch.bind(window), // explicit bind
});Stale data in long-running bots
Read methods don't cache. If you see stale balances after a trade, the venue's API is the bottleneck — Hyperliquid takes ~500ms to reflect a fill. Add a small delay:
await client.placeIntent(openPerp({ ... }));
await new Promise(r => setTimeout(r, 1000));
const balance = await client.getBalance(); // now reflects the new fillFAQ
Do I need to install @axon-trading/cli to use this?
Yes — the SDK talks to the local daemon, which is shipped in the CLI package. Run axon to start it.
Can I use this in serverless?
Yes, but only if your serverless environment can reach the local daemon. Common pattern: run the daemon on a VPS, set AXON_HOST=0.0.0.0 (with AXON_ALLOW_NETWORK_EXPOSURE=1), and have your serverless function point at that VPS. Note: doing so exposes the daemon to the internet — strongly recommended to put a reverse proxy with rate-limiting + IP allowlist in front.
Does the SDK support paper trading?
Yes — start the daemon in demo mode (axon --demo or no flag). All trades are synthesized; balances are local-only. Same SDK calls, same response shapes.
Why are pending actions returned as URLs instead of just signing?
Because the SDK never collects passphrases. The browser confirmation pattern keeps the passphrase off the LLM transcript and out of the SDK's call stack. The user's keystore unlock state lives only in daemon memory.
How do I detect when the daemon restarts?
AxonError with a cause of ECONNREFUSED or repeated UNAUTHORIZED rejections. Or use streamEvents() — when the connection drops, you know.
Can I run the SDK in a Cloudflare Worker / Vercel Edge / Deno?
The SDK is ESM + uses standard fetch. Runtime compatibility is mostly good. Caveat: those edge runtimes can't read ~/.axon/agent.env, so always pass baseUrl + token explicitly.
Does this support both Hyperliquid and Lighter?
The intent API is venue-agnostic. Hyperliquid is wired in v1.0; Lighter is on the v1.1 roadmap with the adapter scaffolded. Use getMarkets("lighter") to see if it's live in your version.
License
Proprietary. © 2026 Strykr Labs. All rights reserved. See LICENSE for the full proprietary notice and TRADEMARKS.md for trademark policy. Licensing inquiries: [email protected].
Source: https://github.com/Strykr-Labs/Axon-Agent-Trading
Docs: https://github.com/Strykr-Labs/Axon-Agent-Trading/tree/main/docs
Companion packages: @axon-trading/cli · @axon-trading/mcp
