SDK reference
Three clients, one method surface. Each is a thin typed wrapper over the REST API — same resources, same parameters, same errors — with idiomatic naming, retries and streaming built in. Nothing in an SDK can do anything the API cannot.
Overview
Pick the SDK that matches where your agent runs. TypeScript is the primary client and ships first; Python and Rust track it within one minor version and are generated from the same OpenAPI document, so the shapes cannot drift.
Parity
| Capability | TypeScript | Python | Rust |
|---|---|---|---|
| Full resource coverage | Yes | Yes | Yes |
| Sync calls | Promise | sync + asyncio | async (tokio) |
| Streaming | Async iterator + handlers | Async generator | Stream impl |
| Automatic retries | Yes | Yes | Yes |
| Webhook signature helper | Yes | Yes | Yes |
| Policy-hash verification | Yes | Yes | Yes |
| Auto-pagination | for await | for … in | try_next() |
| Typed error classes | Yes | Yes | enum StrixError |
| Bundled deployment addresses | Yes | Yes | Yes |
Naming conventions
The wire format is snake_case. Each SDK converts to whatever its ecosystem expects
and converts back on the way out; you never hand-write the wire shape.
| Wire | TypeScript | Python | Rust |
|---|---|---|---|
| max_slippage_bps | maxSlippageBps | max_slippage_bps | max_slippage_bps |
| agent_id | agentId | agent_id | agent_id |
| "150.00" | string | Decimal | str | Decimal |
| 2026-08-16T09:00:00.412Z | Date | datetime (tz-aware) | DateTime<Utc> |
TypeScript keeps them as strings, Python as Decimal, Rust as
rust_decimal::Decimal. Passing a JavaScript number where an amount is
expected is a type error, not a rounding surprise.
Supported runtimes
| SDK | Minimum | Tested on | Notes |
|---|---|---|---|
| TypeScript | Node 20, TS 5.4 | Node 20/22/24, Bun 1.2, Deno 2, modern browsers | ESM and CJS. Browser builds refuse sk_ keys at runtime. |
| Python | 3.10 | 3.10–3.13, CPython and PyPy | httpx transport; sync and async clients share one core. |
| Rust | 1.78, edition 2021 | stable, beta | rustls by default; native-tls behind a feature. |
Installation
No native dependencies, no build step, no post-install scripts in any of the three packages.
Install
npm install @strixhood/sdk
pnpm add @strixhood/sdk
bun add @strixhood/sdk
deno add npm:@strixhood/sdkpip install strixhood
# streaming + webhook helpers
pip install "strixhood[stream,webhooks]"cargo add strix-hood --features rustls,stream
# Cargo.toml
# strix-hood = { version = "1.4", features = ["rustls", "stream"] }Verify the install
Every client exposes ping(), which hits GET /v1/health and returns the
resolved API version. If this works, your key, network path and version pin are all correct.
import { Strix } from "@strixhood/sdk";
const strix = new Strix({ apiKey: process.env.STRIX_API_KEY! });
console.log(await strix.ping());
// { ok: true, apiVersion: "2026-07-01", livemode: false, latencyMs: 41 }import os
from strixhood import Strix
strix = Strix(api_key=os.environ["STRIX_API_KEY"])
print(strix.ping())
# {'ok': True, 'api_version': '2026-07-01', 'livemode': False, 'latency_ms': 41}use strix_hood::Strix;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let strix = Strix::from_env()?;
println!("{:?}", strix.ping().await?);
Ok(())
}Initialisation
One client per API key, constructed once and reused. The clients are connection-pooled and safe to share across concurrent tasks; constructing one per request throws away keep-alive and doubles your latency.
Constructing a client
import { Strix } from "@strixhood/sdk";
export const strix = new Strix({
apiKey: process.env.STRIX_API_KEY!, // required
apiVersion: "2026-07-01", // pin explicitly in production
baseUrl: "https://api.strixhood.xyz/v1", // override for a private deployment
timeoutMs: 20_000,
maxRetries: 4,
telemetry: false,
});import os
from strixhood import Strix, AsyncStrix
strix = Strix(
api_key=os.environ["STRIX_API_KEY"],
api_version="2026-07-01",
base_url="https://api.strixhood.xyz/v1",
timeout=20.0,
max_retries=4,
telemetry=False,
)
# identical surface, awaitable
astrix = AsyncStrix(api_key=os.environ["STRIX_API_KEY"])use std::time::Duration;
use strix_hood::{Strix, StrixConfig};
pub fn client() -> anyhow::Result<Strix> {
Strix::new(StrixConfig {
api_key: std::env::var("STRIX_API_KEY")?,
api_version: "2026-07-01".into(),
base_url: "https://api.strixhood.xyz/v1".parse()?,
timeout: Duration::from_secs(20),
max_retries: 4,
telemetry: false,
})
}Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | string | env STRIX_API_KEY | Required. Prefix decides the environment. |
| apiVersion | string | key default | Pin it. Unpinned clients move when your key's default moves. |
| baseUrl | string | public API | For private deployments and record/replay proxies in tests. |
| timeoutMs | number | 30000 | Per attempt, not per call. A call with 4 retries can take longer. |
| maxRetries | number | 3 | Applies to 429, 5xx and connection errors only. See Retries. |
| idempotencyKey | fn | UUID v4 | Factory for auto-generated keys. Override to derive keys from your own job IDs. |
| fetch / transport | fn | platform default | Inject your own HTTP layer for tracing or proxying. |
| onRequest / onResponse | fn | null | Hooks for logging. Receive method, path, status, requestId, duration. |
| telemetry | boolean | true | Anonymous SDK version and error-class counters. Set false to disable entirely. |
Keys and environments
The key prefix chooses the environment; there is no testMode flag to forget to set.
The browser build refuses to construct a client with an sk_ key and throws
immediately, which is the failure you want at build time rather than the one you find in a bundle
analyser.
// Browser / agent runtime: publishable key only, quotes:read scope.
const preview = new Strix({ apiKey: "strx_pk_live_4c8e1d0b6a92f375" });
const quote = await preview.quotes.create({
action: "swap",
chain: "eip155:8453",
params: { sellToken: "USDC", buyToken: "WETH", sellAmount: "150.00" },
});
// Throws SecretKeyInBrowserError before any network call.
new Strix({ apiKey: "strx_sk_live_9f2c41bd7a084e6cb35d0e17" });Agents
Mirrors the agents endpoints. Every method returns a
fully typed object; nothing is any.
Method surface
| Method | Endpoint | Returns |
|---|---|---|
| agents.create(params) | POST /v1/agents | Agent |
| agents.list(query?) | GET /v1/agents | Page<Agent> |
| agents.get(id, opts?) | GET /v1/agents/{id} | Agent |
| agents.update(id, patch) | PATCH /v1/agents/{id} | Agent |
| agents.retire(id) | DELETE /v1/agents/{id} | Agent |
| agents.sessionKeys.issue(id, params) | POST /v1/agents/{id}/session-keys | SessionKey |
| agents.sessionKeys.revoke(id, keyId) | DELETE /v1/agents/{id}/session-keys/{keyId} | SessionKey |
Create and bind
const agent = await strix.agents.create({
name: "dca-eth",
kind: "trader",
policyId: policy.id,
chains: ["eip155:8453"],
sessionKey: { ttlSeconds: 86_400, rotate: true },
}, { idempotencyKey: "create-dca-eth-01" });
agent.status; // "active"
agent.smartAccount; // "0x1F3c7A9b04E2d586Cf01B7e34a9D2c6058Ba9aE2"
agent.passport.tokenId; // "4182"
agent.policy.hash; // "0x7d41a9c0…"
agent.sessionKeys[0].expiresAt; // Dateagent = strix.agents.create(
name="dca-eth",
kind="trader",
policy_id=policy.id,
chains=["eip155:8453"],
session_key={"ttl_seconds": 86_400, "rotate": True},
idempotency_key="create-dca-eth-01",
)
agent.status # "active"
agent.smart_account # "0x1F3c…9aE2"
agent.passport.token_id # "4182"
agent.session_keys[0].expires_at # datetime, tz-awareuse strix_hood::{CreateAgent, SessionKey};
let agent = strix
.agents()
.create(CreateAgent {
name: "dca-eth".into(),
kind: "trader".into(),
policy_id: policy.id.clone(),
chains: vec!["eip155:8453".into()],
session_key: Some(SessionKey { ttl_seconds: 86_400, rotate: true }),
..Default::default()
})
.idempotency_key("create-dca-eth-01")
.await?;
println!("{} {}", agent.status, agent.smart_account);Listing and auto-pagination
List methods return a page object. Iterating the client-side helper walks every page for you and
stops when has_more is false — you never manage a cursor by hand.
// one page
const page = await strix.agents.list({ status: "active", limit: 25 });
page.data.length; page.hasMore; page.nextCursor;
// every page, lazily
for await (const agent of strix.agents.listAll({ status: "active" })) {
console.log(agent.id, agent.reputation.score);
}page = strix.agents.list(status="active", limit=25)
len(page.data), page.has_more, page.next_cursor
# every page, lazily
for agent in strix.agents.list_all(status="active"):
print(agent.id, agent.reputation.score)use futures::TryStreamExt;
let page = strix.agents().list().status("active").limit(25).await?;
let mut stream = strix.agents().list().status("active").paginate();
while let Some(agent) = stream.try_next().await? {
println!("{} {:?}", agent.id, agent.reputation.score);
}Session keys
Issue short and rotate often. The private key never leaves the enclave, so there is nothing to store, leak or back up on your side — only the key ID matters to you.
const key = await strix.agents.sessionKeys.issue(agent.id, {
ttlSeconds: 3_600,
maxValueUsd: 250,
rotate: true,
});
// ... incident: kill it now
await strix.agents.sessionKeys.revoke(agent.id, key.id);
// { status: "revoked", revocationTx: "0x91cb…f7c9" }Intents
Submitting an intent returns as soon as the policy check passes. Everything after
that is asynchronous, so the SDK gives you two ways to follow it: a stream, or a single
waitFor helper that resolves at a terminal status.
Method surface
| Method | Endpoint | Returns |
|---|---|---|
| intents.create(params, opts?) | POST /v1/intents | Intent |
| intents.get(id, opts?) | GET /v1/intents/{id} | Intent |
| intents.list(query?) | GET /v1/intents | Page<Intent> |
| intents.listAll(query?) | GET /v1/intents | AsyncIterable<Intent> |
| intents.cancel(id) | POST /v1/intents/{id}/cancel | Intent |
| intents.approve(id, params) | POST /v1/intents/{id}/approval | Intent |
| intents.waitFor(id, opts?) | polling + stream | Intent |
Submit
const intent = await strix.intents.create({
agentId: agent.id,
action: "swap",
chain: "eip155:8453",
params: { sellToken: "USDC", buyToken: "WETH", sellAmount: "150.00" },
constraints: { maxSlippageBps: 40, routePreference: "best_price" },
}, { idempotencyKey: "dca-2026-08-16-0900" });
intent.status; // "simulating"
intent.estimated.buyAmount; // "0.04129"
intent.estimated.feeUsd; // 0.375from decimal import Decimal
intent = strix.intents.create(
agent_id=agent.id,
action="swap",
chain="eip155:8453",
params={"sell_token": "USDC", "buy_token": "WETH", "sell_amount": Decimal("150.00")},
constraints={"max_slippage_bps": 40, "route_preference": "best_price"},
idempotency_key="dca-2026-08-16-0900",
)
intent.status # "simulating"
intent.estimated.buy_amount # Decimal("0.04129")use rust_decimal_macros::dec;
use strix_hood::{CreateIntent, Constraints, SwapParams};
let intent = strix
.intents()
.create(CreateIntent {
agent_id: agent.id.clone(),
action: "swap".into(),
chain: "eip155:8453".into(),
params: SwapParams {
sell_token: "USDC".into(),
buy_token: "WETH".into(),
sell_amount: Some(dec!(150.00)),
..Default::default()
}
.into(),
constraints: Some(Constraints {
max_slippage_bps: Some(40),
route_preference: Some("best_price".into()),
..Default::default()
}),
..Default::default()
})
.idempotency_key("dca-2026-08-16-0900")
.await?;Waiting for a terminal status
waitFor opens a stream, falls back to polling if the socket is unavailable, and
resolves on the first terminal status. It rejects on rejected and failed
unless you ask it not to — silent failure is not a default worth having.
import { IntentRejectedError } from "@strixhood/sdk";
try {
const settled = await strix.intents.waitFor(intent.id, { timeoutMs: 90_000 });
console.log(settled.settled.buyAmount, settled.settled.feeUsdSettled);
} catch (err) {
if (err instanceof IntentRejectedError) {
console.error(err.rule, err.message); // "limits.daily_usd", "…exceeds…"
} else {
throw err;
}
}
// or: resolve on any terminal status, never throw
const outcome = await strix.intents.waitFor(intent.id, { throwOnFailure: false });from strixhood.errors import IntentRejected
try:
settled = strix.intents.wait_for(intent.id, timeout=90.0)
print(settled.settled.buy_amount, settled.settled.fee_usd_settled)
except IntentRejected as err:
print(err.rule, err.message)
outcome = strix.intents.wait_for(intent.id, throw_on_failure=False)use std::time::Duration;
use strix_hood::StrixError;
match strix.intents().wait_for(&intent.id, Duration::from_secs(90)).await {
Ok(settled) => println!("{}", settled.settled.buy_amount),
Err(StrixError::IntentRejected { rule, message, .. }) => {
eprintln!("refused by {rule}: {message}");
}
Err(e) => return Err(e.into()),
}Dry runs
simulateOnly runs stages 01–05 and returns the quote plus the signed asset diff
without producing a user operation. It costs no fee and moves no value, which makes it the right
call to put in front of a model before you let it commit.
const preview = await strix.intents.create({
agentId: agent.id,
action: "swap",
chain: "eip155:8453",
params: { sellToken: "USDC", buyToken: "WETH", sellAmount: "150.00" },
simulateOnly: true,
});
preview.status; // "simulated"
preview.simulation.assetDiff; // [{ token: "USDC", delta: "-150.000000" }, …]
preview.simulation.priceImpactBps; // 6
preview.simulation.warnings; // []Resolving human gates
for await (const evt of strix.stream.intents({ statuses: ["awaiting_approval"] })) {
const diff = evt.simulation.assetDiff
.map((d) => `${d.delta} ${d.token}`)
.join(", ");
const ok = await askAHuman(`${evt.agentId} wants: ${diff}`);
await strix.intents.approve(evt.id, {
decision: ok ? "approve" : "reject",
approverId: "usr_01JQ8ZS2M4N6P8R0T2V4X6Z8B0",
note: ok ? "reviewed against mandate" : "outside mandate",
});
}Policies
Policy writes require policies:write. Keep this client in a separate
process from the one your agent talks to.
Method surface
| Method | Endpoint | Returns |
|---|---|---|
| policies.create(doc) | POST /v1/policies | Policy |
| policies.get(id, {version}?) | GET /v1/policies/{id} | Policy |
| policies.list(query?) | GET /v1/policies | Page<Policy> |
| policies.update(id, patch) | PATCH /v1/policies/{id} | Policy |
| policies.simulate(id, {intent}) | POST /v1/policies/{id}/simulate | PolicySimulation |
| policies.hash(doc) | local, no network | Hex32 |
Writing a policy
const policy = await strix.policies.create({
name: "dca-conservative",
limits: { perTxUsd: 250, dailyUsd: 1_000, monthlyUsd: 20_000, maxOpenIntents: 4 },
allow: {
chains: ["eip155:8453"],
actions: ["swap"],
tokens: ["USDC", "WETH"],
venues: ["uniswap_v4", "aerodrome"],
},
deny: { categories: ["leverage", "gambling", "unverified_contract"] },
simulation: { requireSuccess: true, maxPriceImpactBps: 120, minLiquidityUsd: 250_000 },
hitl: { thresholdUsd: 200, channels: ["webhook"], timeoutSec: 180, onTimeout: "reject" },
expiresAt: new Date("2027-01-01T00:00:00Z"),
});from datetime import datetime, timezone
policy = strix.policies.create(
name="dca-conservative",
limits={"per_tx_usd": 250, "daily_usd": 1_000, "monthly_usd": 20_000, "max_open_intents": 4},
allow={
"chains": ["eip155:8453"],
"actions": ["swap"],
"tokens": ["USDC", "WETH"],
"venues": ["uniswap_v4", "aerodrome"],
},
deny={"categories": ["leverage", "gambling", "unverified_contract"]},
simulation={"require_success": True, "max_price_impact_bps": 120,
"min_liquidity_usd": 250_000},
hitl={"threshold_usd": 200, "channels": ["webhook"],
"timeout_sec": 180, "on_timeout": "reject"},
expires_at=datetime(2027, 1, 1, tzinfo=timezone.utc),
)use strix_hood::policy::{Allow, Deny, Hitl, Limits, NewPolicy, Simulation};
let policy = strix
.policies()
.create(NewPolicy {
name: "dca-conservative".into(),
limits: Limits { per_tx_usd: dec!(250), daily_usd: Some(dec!(1000)),
monthly_usd: Some(dec!(20000)), max_open_intents: Some(4),
..Default::default() },
allow: Allow { chains: vec!["eip155:8453".into()],
actions: vec!["swap".into()],
tokens: vec!["USDC".into(), "WETH".into()],
..Default::default() },
deny: Some(Deny { categories: vec!["leverage".into(), "gambling".into()],
..Default::default() }),
simulation: Some(Simulation { require_success: true,
max_price_impact_bps: Some(120),
min_liquidity_usd: Some(dec!(250000)) }),
hitl: Some(Hitl { threshold_usd: Some(dec!(200)), timeout_sec: 180,
on_timeout: "reject".into(), ..Default::default() }),
..Default::default()
})
.await?;Verifying the commitment yourself
policies.hash() runs entirely locally: RFC 8785 canonicalisation, domain separator,
keccak256. Compare it with what the registry holds before you trust an agent with value. If they
disagree, something rewrote your policy.
import { Strix, deployments } from "@strixhood/sdk";
import { createPublicClient, http } from "viem";
import { base } from "viem/chains";
const doc = await strix.policies.get(policy.id);
const local = strix.policies.hash(doc); // pure function, no network
const chain = createPublicClient({ chain: base, transport: http() });
const onchain = await chain.readContract({
address: deployments.base.PolicyRegistry,
abi: deployments.abi.PolicyRegistry,
functionName: "policyOf",
args: [BigInt(agent.passport.tokenId)],
});
if (local !== onchain) {
await strix.agents.update(agent.id, { status: "paused" });
throw new Error(`policy drift: local ${local} vs registry ${onchain}`);
}Policy tests in CI
policies.simulate() is the assertion primitive. Enumerate the intents your agent could
produce and assert on the outcome — this catches a widened policy in review, not in production.
import { expect, test } from "vitest";
const cases = [
{ label: "small swap passes", amount: "100.00", allowed: true },
{ label: "over per-tx cap", amount: "800.00", allowed: false, rule: "limits.per_tx_usd" },
];
for (const c of cases) {
test(c.label, async () => {
const sim = await strix.policies.simulate(policy.id, {
intent: {
action: "swap",
chain: "eip155:8453",
params: { sellToken: "USDC", buyToken: "WETH", sellAmount: c.amount },
},
});
expect(sim.allowed).toBe(c.allowed);
if (c.rule) expect(sim.firstFailure).toBe(c.rule);
});
}Quotes, executions & portfolio
Read paths. All three are safe to call with a publishable key except
executions, which needs intents:read.
Quotes and routes
const quote = await strix.quotes.create({
action: "swap",
chain: "eip155:8453",
params: { sellToken: "USDC", buyToken: "WETH", sellAmount: "150.00" },
agentId: agent.id, // also returns policyOk
});
quote.buy.minimum; // "0.041128" — the guaranteed floor
quote.priceImpactBps; // 6
quote.policyOk; // true
quote.expiresAt; // Date, ~12 s out
quote.routes.map((r) => [r.solver, r.venue, r.out]);
const venues = await strix.routes.list({ chain: "eip155:42161", kind: "rwa_equity" });
venues.data[0].session; // { opensAt, closesAt, continuousSecondary }quote = strix.quotes.create(
action="swap",
chain="eip155:8453",
params={"sell_token": "USDC", "buy_token": "WETH", "sell_amount": "150.00"},
agent_id=agent.id,
)
quote.buy.minimum # Decimal("0.041128")
quote.price_impact_bps # 6
quote.policy_ok # True
venues = strix.routes.list(chain="eip155:42161", kind="rwa_equity")
venues.data[0].sessionlet quote = strix
.quotes()
.create(NewQuote {
action: "swap".into(),
chain: "eip155:8453".into(),
params: SwapParams { sell_token: "USDC".into(), buy_token: "WETH".into(),
sell_amount: Some(dec!(150.00)), ..Default::default() }.into(),
agent_id: Some(agent.id.clone()),
..Default::default()
})
.await?;
println!("floor {} impact {}bps", quote.buy.minimum, quote.price_impact_bps);Executions and attestations
const exec = await strix.executions.get(intent.executionId!);
exec.solver.id; // "slv_kestrel"
exec.quote.bidsReceived; // 3
exec.quote.auctionMs; // 176
exec.fills[0].txHash; // "0x7c02e9…d3a2"
exec.fees.split; // { treasuryUsd, stakersUsd, buybackUsd }
// the audit artefact, decoded plus raw for independent verification
const att = await strix.executions.attestation(exec.id);
att.data.policyHash === agent.policy.hash; // true
att.verifyUrl; // easscan link
// monthly CSV for accounting
const csv = await strix.executions.export({
settledAfter: new Date("2026-07-01"),
settledBefore: new Date("2026-08-01"),
});Portfolio
const pf = await strix.portfolio.get({ agentId: agent.id, minValueUsd: 1 });
pf.totalValueUsd; // 48213.77
pf.change24hPct; // 1.84
pf.tokens.find((t) => t.symbol === "WETH")?.allocationPct; // 71
const history = await strix.portfolio.history({
agentId: agent.id, interval: "1h", range: "24h",
});
for await (const txn of strix.portfolio.transactionsAll({ agentId: agent.id })) {
if (txn.intentId === null) console.log("external movement", txn.kind, txn.valueUsd);
}pf = strix.portfolio.get(agent_id=agent.id, min_value_usd=1)
pf.total_value_usd # Decimal("48213.77")
pf.change_24h_pct # 1.84
history = strix.portfolio.history(agent_id=agent.id, interval="1h", range="24h")
for txn in strix.portfolio.transactions_all(agent_id=agent.id):
if txn.intent_id is None:
print("external movement", txn.kind, txn.value_usd)let pf = strix.portfolio().get(&agent.id).min_value_usd(dec!(1)).await?;
println!("{} ({:+.2}%)", pf.total_value_usd, pf.change_24h_pct);
let mut txns = strix.portfolio().transactions(&agent.id).paginate();
while let Some(txn) = txns.try_next().await? {
if txn.intent_id.is_none() {
println!("external movement {} {}", txn.kind, txn.value_usd);
}
}Streaming
The SDK owns the socket: authentication, heartbeats, resume-from-sequence and jittered reconnect. You get an async iterator that does not end when the connection drops.
Async iteration
const stream = strix.stream.executions({
agentIds: [agent.id],
chains: ["eip155:8453"],
});
for await (const evt of stream) {
switch (evt.status) {
case "submitted":
console.log("broadcast", evt.fills[0]?.txHash);
break;
case "settled":
console.log("filled", evt.fills[0].buyAmount, "att", evt.attestation.uid);
break;
case "failed":
console.error("reverted", evt.failure?.reason);
break;
}
}
// stop cleanly — closes the socket and ends the iterator
await stream.close();import asyncio
from strixhood import AsyncStrix
async def main() -> None:
strix = AsyncStrix(api_key=os.environ["STRIX_API_KEY"])
async with strix.stream.executions(agent_ids=[agent.id]) as stream:
async for evt in stream:
if evt.status == "settled":
print("filled", evt.fills[0].buy_amount)
elif evt.status == "failed":
print("reverted", evt.failure.reason)
asyncio.run(main())use futures::StreamExt;
use strix_hood::stream::ExecutionFilter;
let mut stream = strix
.stream()
.executions(ExecutionFilter { agent_ids: vec![agent.id.clone()], ..Default::default() })
.await?;
while let Some(evt) = stream.next().await {
let evt = evt?;
match evt.status.as_str() {
"settled" => println!("filled {}", evt.fills[0].buy_amount),
"failed" => eprintln!("reverted {:?}", evt.failure),
_ => {}
}
}Event handlers
If an iterator does not fit your architecture, subscribe with callbacks instead. Both forms share one underlying socket per client, so mixing them does not open a second connection or count twice against the tier limit.
const sub = strix.stream.subscribe({
channels: [
{ name: "intents", agentIds: [agent.id] },
{ name: "prices", symbols: ["ETH"] },
],
onIntent: (i) => metrics.observe(i.status),
onPrice: (p) => { if (p.stalenessMs > 5_000) pauseTrading(); },
onReconnect: (attempt, gap) => log.warn({ attempt, replayed: gap }, "stream resumed"),
onError: (err) => log.error(err),
});
// later
sub.unsubscribe(["prices"]);
await sub.close();Reconnection and gaps
The client tracks the last seq per channel and resumes from it. The server buffers 15
minutes; if the gap is longer the SDK emits onGap with the range it could not replay so
you can backfill over REST rather than silently losing events.
strix.stream.subscribe({
channels: [{ name: "executions", agentIds: [agent.id] }],
onExecution: handle,
onGap: async ({ channel, fromSeq, toSeq, since }) => {
log.warn({ channel, fromSeq, toSeq }, "buffer exceeded, backfilling over REST");
for await (const exec of strix.executions.listAll({
agentId: agent.id,
settledAfter: since,
})) {
handle(exec);
}
},
});| Behaviour | Default | Option |
|---|---|---|
| Reconnect backoff | 500 ms → 30 s, full jitter | reconnect.maxDelayMs |
| Reconnect attempts | unbounded | reconnect.maxAttempts |
| Fatal close codes | 4001, 4003 — never retried | — |
| Heartbeat | server-driven, auto-answered | — |
| Resume window | 15 minutes of buffer | resume: false |
Webhooks
Each SDK ships a constant-time verifier and adapters for the common server frameworks. The verifier needs the raw body — every adapter below is built around getting you that before anything parses it.
Verify a delivery
import express from "express";
import { Strix, WebhookSignatureError } from "@strixhood/sdk";
const app = express();
const strix = new Strix({ apiKey: process.env.STRIX_API_KEY! });
// raw body, not express.json()
app.post("/hooks/strix", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = strix.webhooks.verify({
payload: req.body, // Buffer
signature: req.header("Strix-Signature")!,
secret: process.env.STRIX_WEBHOOK_SECRET!,
toleranceSeconds: 300,
});
} catch (err) {
if (err instanceof WebhookSignatureError) return res.sendStatus(400);
throw err;
}
res.sendStatus(202); // acknowledge first
void queue.push(event); // then do the work
});from fastapi import FastAPI, Request, Response, BackgroundTasks
from strixhood import Strix
from strixhood.errors import WebhookSignatureError
app = FastAPI()
strix = Strix(api_key=os.environ["STRIX_API_KEY"])
@app.post("/hooks/strix")
async def hook(request: Request, tasks: BackgroundTasks) -> Response:
raw = await request.body()
try:
event = strix.webhooks.verify(
payload=raw,
signature=request.headers["strix-signature"],
secret=os.environ["STRIX_WEBHOOK_SECRET"],
tolerance_seconds=300,
)
except WebhookSignatureError:
return Response(status_code=400)
tasks.add_task(handle, event)
return Response(status_code=202)use axum::{body::Bytes, http::{HeaderMap, StatusCode}};
use strix_hood::webhooks;
async fn hook(headers: HeaderMap, body: Bytes) -> StatusCode {
let sig = match headers.get("strix-signature").and_then(|v| v.to_str().ok()) {
Some(s) => s,
None => return StatusCode::BAD_REQUEST,
};
let event = match webhooks::verify(&body, sig, secret(), 300) {
Ok(e) => e,
Err(_) => return StatusCode::BAD_REQUEST,
};
tokio::spawn(handle(event));
StatusCode::ACCEPTED
}Deduplicating deliveries
Delivery is at-least-once and unordered. Two lines of defence cover both: dedupe on
event.id, and treat the status machine as the source of truth rather than arrival
order.
const RANK = {
received: 0, policy_check: 1, awaiting_approval: 2, simulating: 3,
routing: 4, submitted: 5, settled: 6, rejected: 6, failed: 6, expired: 6, cancelled: 6,
} as const;
async function handle(event: StrixEvent) {
if (await seen.has(event.id)) return; // at-least-once → dedupe
await seen.add(event.id, { ttlSeconds: 172_800 });
const next = event.data.object;
const prev = await db.intents.get(next.id);
// out-of-order retry: never move an intent backwards
if (prev && RANK[next.status] < RANK[prev.status]) return;
await db.intents.upsert(next);
}Errors & retries
Every SDK error carries the full API envelope — type,
code, param, rule, requestId — and the classes
map one-to-one onto the error-code table.
Error classes
| TypeScript | Python | Rust variant | Retryable |
|---|---|---|---|
| AuthenticationError | AuthenticationError | StrixError::Auth | No |
| PermissionError | PermissionError | StrixError::Permission | No |
| InvalidRequestError | InvalidRequest | StrixError::InvalidRequest | No |
| PolicyError | PolicyError | StrixError::Policy | No |
| SimulationError | SimulationError | StrixError::Simulation | No |
| RoutingError | RoutingError | StrixError::Routing | Sometimes — quote_expired only |
| IdempotencyError | IdempotencyError | StrixError::Idempotency | No |
| RateLimitError | RateLimitError | StrixError::RateLimit | Yes |
| ApiError | ApiError | StrixError::Api | Yes |
| ConnectionError | ConnectionError | StrixError::Transport | Yes |
| IntentRejectedError | IntentRejected | StrixError::IntentRejected | No — raised by waitFor |
import { PolicyError, RateLimitError, StrixError } from "@strixhood/sdk";
try {
await strix.intents.create(body);
} catch (err) {
if (err instanceof PolicyError) {
// err.rule === "limits.daily_usd"; not retryable, the budget is spent
metrics.inc("policy_refusal", { rule: err.rule });
await pauseUntilWindowResets(err.rule);
} else if (err instanceof RateLimitError) {
await sleep(err.retryAfterMs);
} else if (err instanceof StrixError) {
log.error({ requestId: err.requestId, code: err.code }, err.message);
throw err;
} else {
throw err;
}
}from strixhood.errors import PolicyError, RateLimitError, StrixError
try:
strix.intents.create(**body)
except PolicyError as err:
metrics.inc("policy_refusal", rule=err.rule)
pause_until_window_resets(err.rule)
except RateLimitError as err:
time.sleep(err.retry_after)
except StrixError as err:
log.error("%s %s: %s", err.request_id, err.code, err.message)
raiseuse strix_hood::StrixError;
match strix.intents().create(body).await {
Ok(intent) => Ok(intent),
Err(StrixError::Policy { rule, .. }) => {
metrics::inc("policy_refusal", &rule);
pause_until_window_resets(&rule).await;
Err(anyhow!("refused by {rule}"))
}
Err(StrixError::RateLimit { retry_after, .. }) => {
tokio::time::sleep(retry_after).await;
Err(anyhow!("rate limited"))
}
Err(e) => Err(e.into()),
}Retries
Retries are automatic for 429, 5xx and transport errors, with full-jitter
exponential backoff that honours Retry-After. Everything else fails immediately,
because retrying a policy refusal just refuses again.
const strix = new Strix({
apiKey: process.env.STRIX_API_KEY!,
maxRetries: 5,
retry: {
baseDelayMs: 250,
maxDelayMs: 8_000,
jitter: "full",
honourRetryAfter: true,
retryOn: (err) => err.status === 429 || err.status >= 500,
},
});
// per call
await strix.intents.create(body, { maxRetries: 0 }); // never retry this one
await strix.portfolio.get({ agentId }, { maxRetries: 8 }); // read path, retry harderThe SDK attaches an Idempotency-Key to every POST automatically and
reuses it across retries of the same call, so a retried submit can never create a second
intent. If you supply your own key, make it unique per logical operation — not per process.
Timeouts and cancellation
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
await strix.quotes.create(body, { signal: ac.signal, timeoutMs: 4_000, maxRetries: 0 });
// Streams take a signal too — aborting closes the socket and ends the iterator.
for await (const evt of strix.stream.executions({ signal: ac.signal })) { /* … */ }Python uses timeout= plus asyncio.CancelledError; Rust composes with
tokio::select! and CancellationToken. In all three, cancelling a request
that has already been accepted by the API does not cancel the intent — call
intents.cancel() for that.
Logging and tracing
const strix = new Strix({
apiKey: process.env.STRIX_API_KEY!,
onRequest: ({ method, path, attempt, idempotencyKey }) =>
log.debug({ method, path, attempt, idempotencyKey }, "strix →"),
onResponse: ({ status, requestId, durationMs, retryCount }) =>
log.debug({ status, requestId, durationMs, retryCount }, "strix ←"),
});Log requestId on every response. It is the only identifier that resolves to the
policy evaluation and simulation transcript on our side, and it is the first thing support asks
for.
Worked example: a DCA agent under a policy cap
Build an agent that buys $150 of ETH every weekday at 09:00 UTC, never spends more than $250 in one intent or $1,000 in a day, escalates anything above $200 to a human, and stops itself if the policy it was issued under ever changes. Roughly 120 lines, no framework.
Shape of the program
| Process | Key | Responsibility |
|---|---|---|
setup.ts, run once | sk_ · policies:write, agents:write | Creates the policy, registers the agent, binds them. |
dca.ts, long-running | sk_ · intents:write, intents:read | Submits one intent per weekday, follows it to settlement, halts on drift. |
approve.ts, long-running | sk_ · intents:write | Watches the human gate and routes it to a person. |
Splitting the write scopes matters. The DCA loop is the process most likely to be compromised — it is the one taking instructions from a schedule and a market — and it holds no authority to widen its own limits.
1. Policy and agent
import { Strix } from "@strixhood/sdk";
const admin = new Strix({ apiKey: process.env.STRIX_ADMIN_KEY! }); // policies:write
const policy = await admin.policies.create({
name: "dca-eth-weekday",
limits: {
perTxUsd: 250,
dailyUsd: 1_000,
monthlyUsd: 20_000,
maxOpenIntents: 2,
gasBudgetDailyUsd: 5,
},
allow: {
chains: ["eip155:8453"],
actions: ["swap"],
tokens: ["USDC", "WETH"],
venues: ["uniswap_v4", "aerodrome"],
},
deny: { categories: ["leverage", "gambling", "unverified_contract", "low_liquidity"] },
simulation: { requireSuccess: true, maxPriceImpactBps: 80, minLiquidityUsd: 500_000 },
hitl: {
thresholdUsd: 200,
channels: ["webhook"],
timeoutSec: 600,
onTimeout: "reject",
approvers: [process.env.APPROVER_ID!],
quorum: 1,
},
expiresAt: new Date("2027-01-01T00:00:00Z"),
});
const agent = await admin.agents.create({
name: "dca-eth",
kind: "trader",
policyId: policy.id,
chains: ["eip155:8453"],
sessionKey: { ttlSeconds: 86_400, rotate: true },
}, { idempotencyKey: "bootstrap-dca-eth-v1" });
console.log({
agent: agent.id,
account: agent.smartAccount,
policyHash: policy.hash, // pin this in your config; the loop checks it
});2. The loop
Two things make this safe rather than merely automated. The idempotency key is derived from the calendar day, so a crash-restart at 09:00:03 cannot double-buy. And the policy hash is verified before every submission, so a policy rewritten out from under the agent halts it instead of widening it.
import { Strix, PolicyError, IntentRejectedError } from "@strixhood/sdk";
const strix = new Strix({ apiKey: process.env.STRIX_API_KEY!, maxRetries: 4 });
const AGENT_ID = process.env.STRIX_AGENT_ID!;
const PINNED_POLICY_HASH = process.env.STRIX_POLICY_HASH!; // from setup.ts
const DAILY_USD = "150.00";
/** Refuse to trade if the policy is not the one we were reviewed against. */
async function assertPolicyUnchanged(): Promise<void> {
const agent = await strix.agents.get(AGENT_ID, { expand: ["policy"] });
if (agent.policy.hash !== PINNED_POLICY_HASH) {
await strix.agents.update(AGENT_ID, { status: "paused" });
throw new Error(
`policy drift: expected ${PINNED_POLICY_HASH}, registry holds ${agent.policy.hash}`,
);
}
if (agent.status !== "active") throw new Error(`agent is ${agent.status}`);
}
async function buyOnce(day: string): Promise<void> {
await assertPolicyUnchanged();
// Price it first so we can refuse obviously bad conditions before committing.
const quote = await strix.quotes.create({
action: "swap",
chain: "eip155:8453",
params: { sellToken: "USDC", buyToken: "WETH", sellAmount: DAILY_USD },
agentId: AGENT_ID,
});
if (quote.priceImpactBps > 40) {
console.warn(`skipping ${day}: impact ${quote.priceImpactBps}bps`);
return;
}
const intent = await strix.intents.create({
agentId: AGENT_ID,
action: "swap",
chain: "eip155:8453",
params: { sellToken: "USDC", buyToken: "WETH", sellAmount: DAILY_USD },
constraints: {
maxSlippageBps: 40,
maxFeeUsd: 1.5,
routePreference: "best_price",
mevProtection: true,
},
metadata: { strategy: "dca-eth-weekday", day },
}, { idempotencyKey: `dca-eth-${day}` }); // one buy per calendar day, forever
const settled = await strix.intents.waitFor(intent.id, { timeoutMs: 120_000 });
console.log(day, "filled", settled.settled.buyAmount, "WETH",
"fee", settled.settled.feeUsdSettled);
}
export async function tick(now: Date): Promise<void> {
const day = now.toISOString().slice(0, 10); // 2026-08-16
const weekday = now.getUTCDay() >= 1 && now.getUTCDay() <= 5;
if (!weekday) return;
try {
await buyOnce(day);
} catch (err) {
if (err instanceof IntentRejectedError) {
// Terminal and expected: budget spent, gate declined, or simulation refused.
console.warn(day, "refused:", err.rule ?? err.code, err.message);
return;
}
if (err instanceof PolicyError && err.code === "limit_exceeded") {
console.warn(day, "window exhausted:", err.rule);
return;
}
throw err; // unknown failure: let the supervisor restart us loudly
}
}3. The human gate
Anything over $200 pauses at awaiting_approval with a ten-minute clock and a fail-safe
default. This worker turns that into a message a person can answer, and answers it back.
import { Strix } from "@strixhood/sdk";
const strix = new Strix({ apiKey: process.env.STRIX_APPROVAL_KEY! });
for await (const intent of strix.stream.intents({
agentIds: [process.env.STRIX_AGENT_ID!],
statuses: ["awaiting_approval"],
})) {
const diff = intent.simulation.assetDiff
.map((d) => `${d.delta} ${d.token}`)
.join(" ");
const decision = await ask({
title: `${intent.agentId} · ${intent.action}`,
body: [
`notional ${intent.policy.notionalUsd} USD`,
`diff ${diff}`,
`impact ${intent.simulation.priceImpactBps} bps`,
`expires ${intent.expiresAt.toISOString()}`,
].join("\n"),
choices: ["approve", "reject"] as const,
});
await strix.intents.approve(intent.id, {
decision,
approverId: process.env.APPROVER_ID!,
note: `answered in ${Date.now() - intent.createdAt.getTime()}ms`,
});
}The same loop in Python
import asyncio, os
from datetime import datetime, timezone
from decimal import Decimal
from strixhood import AsyncStrix
from strixhood.errors import IntentRejected, PolicyError
strix = AsyncStrix(api_key=os.environ["STRIX_API_KEY"], max_retries=4)
AGENT_ID = os.environ["STRIX_AGENT_ID"]
PINNED_POLICY_HASH = os.environ["STRIX_POLICY_HASH"]
DAILY_USD = Decimal("150.00")
async def assert_policy_unchanged() -> None:
agent = await strix.agents.get(AGENT_ID, expand=["policy"])
if agent.policy.hash != PINNED_POLICY_HASH:
await strix.agents.update(AGENT_ID, status="paused")
raise RuntimeError(f"policy drift: registry holds {agent.policy.hash}")
if agent.status != "active":
raise RuntimeError(f"agent is {agent.status}")
async def buy_once(day: str) -> None:
await assert_policy_unchanged()
quote = await strix.quotes.create(
action="swap",
chain="eip155:8453",
params={"sell_token": "USDC", "buy_token": "WETH", "sell_amount": DAILY_USD},
agent_id=AGENT_ID,
)
if quote.price_impact_bps > 40:
print(f"skipping {day}: impact {quote.price_impact_bps}bps")
return
intent = await strix.intents.create(
agent_id=AGENT_ID,
action="swap",
chain="eip155:8453",
params={"sell_token": "USDC", "buy_token": "WETH", "sell_amount": DAILY_USD},
constraints={"max_slippage_bps": 40, "max_fee_usd": 1.5,
"route_preference": "best_price", "mev_protection": True},
metadata={"strategy": "dca-eth-weekday", "day": day},
idempotency_key=f"dca-eth-{day}",
)
settled = await strix.intents.wait_for(intent.id, timeout=120.0)
print(day, "filled", settled.settled.buy_amount, "WETH")
async def tick(now: datetime) -> None:
if now.weekday() > 4:
return
day = now.date().isoformat()
try:
await buy_once(day)
except IntentRejected as err:
print(day, "refused:", err.rule or err.code, err.message)
except PolicyError as err:
if err.code != "limit_exceeded":
raise
print(day, "window exhausted:", err.rule)
if __name__ == "__main__":
asyncio.run(tick(datetime.now(timezone.utc)))What this buys you
| Failure | What happens |
|---|---|
| The scheduler fires twice at 09:00 | Second call replays the idempotency key and returns the first intent. One buy. |
| The process crashes mid-intent | Restart re-submits with the same key, gets the in-flight intent back, resumes waiting. |
| Someone widens the policy | Hash mismatch on the next tick: the agent is paused and the loop throws before submitting. |
| The model is tricked into a larger buy | Not reachable — the amount is a constant in this process, and the policy caps it at $250 regardless. |
| A thin market moves against the buy | Quote check skips above 40 bps; the 40 bps slippage bound is enforced onchain if it slips after quoting. |
| The daily cap is already spent | limit_exceeded on limits.daily_usd, logged and skipped. Nothing is broadcast. |
| Nobody answers the approval | After 600 s on_timeout: "reject" fires. The intent is rejected, not silently executed. |
| The session key leaks | It expires in 24 hours, is capped at $250 per transaction, and can only call the swap selectors on Base. |
Run the whole thing against a strx_sk_test_ key on Base Sepolia first, then set
simulateOnly: true on mainnet for a day and diff the quotes against what you
expected. Only then remove the flag.
Method index
Every method in the TypeScript client and the endpoint behind it. Python and Rust expose the same list under their own naming conventions.
| Method | Endpoint | Scope |
|---|---|---|
| ping() | GET /v1/health | any |
| agents.create() | POST /v1/agents | agents:write |
| agents.list() / listAll() | GET /v1/agents | agents:read |
| agents.get() | GET /v1/agents/{id} | agents:read |
| agents.update() | PATCH /v1/agents/{id} | agents:write |
| agents.retire() | DELETE /v1/agents/{id} | agents:write |
| agents.sessionKeys.issue() | POST /v1/agents/{id}/session-keys | agents:write |
| agents.sessionKeys.revoke() | DELETE /v1/agents/{id}/session-keys/{keyId} | agents:write |
| intents.create() | POST /v1/intents | intents:write |
| intents.get() | GET /v1/intents/{id} | intents:read |
| intents.list() / listAll() | GET /v1/intents | intents:read |
| intents.cancel() | POST /v1/intents/{id}/cancel | intents:write |
| intents.approve() | POST /v1/intents/{id}/approval | intents:write |
| intents.waitFor() | stream + poll | intents:read |
| policies.create() | POST /v1/policies | policies:write |
| policies.get() | GET /v1/policies/{id} | policies:read |
| policies.list() | GET /v1/policies | policies:read |
| policies.update() | PATCH /v1/policies/{id} | policies:write |
| policies.simulate() | POST /v1/policies/{id}/simulate | policies:read |
| policies.hash() | local | — |
| quotes.create() | POST /v1/quotes | quotes:read |
| quotes.get() | GET /v1/quotes/{id} | quotes:read |
| routes.list() | GET /v1/routes | quotes:read |
| executions.get() | GET /v1/executions/{id} | intents:read |
| executions.list() / listAll() | GET /v1/executions | intents:read |
| executions.export() | GET /v1/executions?format=csv | intents:read |
| executions.attestation() | GET /v1/executions/{id}/attestation | intents:read |
| portfolio.get() | GET /v1/portfolio | portfolio:read |
| portfolio.history() | GET /v1/portfolio/history | portfolio:read |
| portfolio.transactionsAll() | GET /v1/portfolio/transactions | portfolio:read |
| webhooks.create() | POST /v1/webhooks | webhooks:write |
| webhooks.list() | GET /v1/webhooks | webhooks:write |
| webhooks.delete() | DELETE /v1/webhooks/{id} | webhooks:write |
| webhooks.replay() | POST /v1/webhooks/{id}/replay | webhooks:write |
| webhooks.verify() | local | — |
| stream.intents() | wss · channel intents | intents:read |
| stream.executions() | wss · channel executions | intents:read |
| stream.prices() | wss · channel prices | quotes:read |
| stream.subscribe() | wss · multiplexed | per channel |