SDK/Overview

TypeScript · Python · Rust

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.

TypeScript @strixhood/sdk 1.4.2 Python strixhood 1.4.2 Rust strix-hood 1.4.2

Orientation

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

CapabilityTypeScriptPythonRust
Full resource coverageYesYesYes
Sync callsPromisesync + asyncioasync (tokio)
StreamingAsync iterator + handlersAsync generatorStream impl
Automatic retriesYesYesYes
Webhook signature helperYesYesYes
Policy-hash verificationYesYesYes
Auto-paginationfor awaitfor … intry_next()
Typed error classesYesYesenum StrixError
Bundled deployment addressesYesYesYes

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.

WireTypeScriptPythonRust
max_slippage_bpsmaxSlippageBpsmax_slippage_bpsmax_slippage_bps
agent_idagentIdagent_idagent_id
"150.00"stringDecimal | strDecimal
2026-08-16T09:00:00.412ZDatedatetime (tz-aware)DateTime<Utc>
Amounts stay decimal, never float

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

SDKMinimumTested onNotes
TypeScriptNode 20, TS 5.4Node 20/22/24, Bun 1.2, Deno 2, modern browsersESM and CJS. Browser builds refuse sk_ keys at runtime.
Python3.103.10–3.13, CPython and PyPyhttpx transport; sync and async clients share one core.
Rust1.78, edition 2021stable, betarustls by default; native-tls behind a feature.
Setup

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/sdk
pip 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(())
}
Client

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

OptionTypeDefaultDescription
apiKeystringenv STRIX_API_KEYRequired. Prefix decides the environment.
apiVersionstringkey defaultPin it. Unpinned clients move when your key's default moves.
baseUrlstringpublic APIFor private deployments and record/replay proxies in tests.
timeoutMsnumber30000Per attempt, not per call. A call with 4 retries can take longer.
maxRetriesnumber3Applies to 429, 5xx and connection errors only. See Retries.
idempotencyKeyfnUUID v4Factory for auto-generated keys. Override to derive keys from your own job IDs.
fetch / transportfnplatform defaultInject your own HTTP layer for tracing or proxying.
onRequest / onResponsefnnullHooks for logging. Receive method, path, status, requestId, duration.
telemetrybooleantrueAnonymous 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" });
Resource

Agents

Mirrors the agents endpoints. Every method returns a fully typed object; nothing is any.

Method surface

MethodEndpointReturns
agents.create(params)POST /v1/agentsAgent
agents.list(query?)GET /v1/agentsPage<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-keysSessionKey
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; // Date
agent = 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-aware
use 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" }
Resource

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

MethodEndpointReturns
intents.create(params, opts?)POST /v1/intentsIntent
intents.get(id, opts?)GET /v1/intents/{id}Intent
intents.list(query?)GET /v1/intentsPage<Intent>
intents.listAll(query?)GET /v1/intentsAsyncIterable<Intent>
intents.cancel(id)POST /v1/intents/{id}/cancelIntent
intents.approve(id, params)POST /v1/intents/{id}/approvalIntent
intents.waitFor(id, opts?)polling + streamIntent

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.375
from 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",
  });
}
Resource

Policies

Policy writes require policies:write. Keep this client in a separate process from the one your agent talks to.

Method surface

MethodEndpointReturns
policies.create(doc)POST /v1/policiesPolicy
policies.get(id, {version}?)GET /v1/policies/{id}Policy
policies.list(query?)GET /v1/policiesPage<Policy>
policies.update(id, patch)PATCH /v1/policies/{id}Policy
policies.simulate(id, {intent})POST /v1/policies/{id}/simulatePolicySimulation
policies.hash(doc)local, no networkHex32

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);
  });
}
Resource

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].session
let 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);
    }
}
Realtime

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);
    }
  },
});
BehaviourDefaultOption
Reconnect backoff500 ms → 30 s, full jitterreconnect.maxDelayMs
Reconnect attemptsunboundedreconnect.maxAttempts
Fatal close codes4001, 4003 — never retried
Heartbeatserver-driven, auto-answered
Resume window15 minutes of bufferresume: false
Realtime

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);
}
Failure

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

TypeScriptPythonRust variantRetryable
AuthenticationErrorAuthenticationErrorStrixError::AuthNo
PermissionErrorPermissionErrorStrixError::PermissionNo
InvalidRequestErrorInvalidRequestStrixError::InvalidRequestNo
PolicyErrorPolicyErrorStrixError::PolicyNo
SimulationErrorSimulationErrorStrixError::SimulationNo
RoutingErrorRoutingErrorStrixError::RoutingSometimes — quote_expired only
IdempotencyErrorIdempotencyErrorStrixError::IdempotencyNo
RateLimitErrorRateLimitErrorStrixError::RateLimitYes
ApiErrorApiErrorStrixError::ApiYes
ConnectionErrorConnectionErrorStrixError::TransportYes
IntentRejectedErrorIntentRejectedStrixError::IntentRejectedNo — 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)
    raise
use 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 harder
Retries and idempotency go together

The 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.

End to end

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

ProcessKeyResponsibility
setup.ts, run oncesk_ · policies:write, agents:writeCreates the policy, registers the agent, binds them.
dca.ts, long-runningsk_ · intents:write, intents:readSubmits one intent per weekday, follows it to settlement, halts on drift.
approve.ts, long-runningsk_ · intents:writeWatches 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

FailureWhat happens
The scheduler fires twice at 09:00Second call replays the idempotency key and returns the first intent. One buy.
The process crashes mid-intentRestart re-submits with the same key, gets the in-flight intent back, resumes waiting.
Someone widens the policyHash mismatch on the next tick: the agent is paused and the loop throws before submitting.
The model is tricked into a larger buyNot reachable — the amount is a constant in this process, and the policy caps it at $250 regardless.
A thin market moves against the buyQuote check skips above 40 bps; the 40 bps slippage bound is enforced onchain if it slips after quoting.
The daily cap is already spentlimit_exceeded on limits.daily_usd, logged and skipped. Nothing is broadcast.
Nobody answers the approvalAfter 600 s on_timeout: "reject" fires. The intent is rejected, not silently executed.
The session key leaksIt expires in 24 hours, is capped at $250 per transaction, and can only call the swap selectors on Base.
Test it before you fund it

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.

Lookup

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.

MethodEndpointScope
ping()GET /v1/healthany
agents.create()POST /v1/agentsagents:write
agents.list() / listAll()GET /v1/agentsagents: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-keysagents:write
agents.sessionKeys.revoke()DELETE /v1/agents/{id}/session-keys/{keyId}agents:write
intents.create()POST /v1/intentsintents:write
intents.get()GET /v1/intents/{id}intents:read
intents.list() / listAll()GET /v1/intentsintents:read
intents.cancel()POST /v1/intents/{id}/cancelintents:write
intents.approve()POST /v1/intents/{id}/approvalintents:write
intents.waitFor()stream + pollintents:read
policies.create()POST /v1/policiespolicies:write
policies.get()GET /v1/policies/{id}policies:read
policies.list()GET /v1/policiespolicies:read
policies.update()PATCH /v1/policies/{id}policies:write
policies.simulate()POST /v1/policies/{id}/simulatepolicies:read
policies.hash()local
quotes.create()POST /v1/quotesquotes:read
quotes.get()GET /v1/quotes/{id}quotes:read
routes.list()GET /v1/routesquotes:read
executions.get()GET /v1/executions/{id}intents:read
executions.list() / listAll()GET /v1/executionsintents:read
executions.export()GET /v1/executions?format=csvintents:read
executions.attestation()GET /v1/executions/{id}/attestationintents:read
portfolio.get()GET /v1/portfolioportfolio:read
portfolio.history()GET /v1/portfolio/historyportfolio:read
portfolio.transactionsAll()GET /v1/portfolio/transactionsportfolio:read
webhooks.create()POST /v1/webhookswebhooks:write
webhooks.list()GET /v1/webhookswebhooks:write
webhooks.delete()DELETE /v1/webhooks/{id}webhooks:write
webhooks.replay()POST /v1/webhooks/{id}/replaywebhooks:write
webhooks.verify()local
stream.intents()wss · channel intentsintents:read
stream.executions()wss · channel executionsintents:read
stream.prices()wss · channel pricesquotes:read
stream.subscribe()wss · multiplexedper channel