@operon/sdk
v0.3.0
Published
Operon SDK - request placements from the Operon ad network
Maintainers
Readme
@operon/sdk
Operon's TypeScript SDK. Connect any agent to the ad network.
Install
npm install @operon/sdkWorks in both ESM and CommonJS Node.js projects. Node 18+.
Quickstart
The SDK ships dual ESM + CJS builds, so the same package works in both
project styles. Pick the snippet that matches your project's "type"
field in package.json (default for npm init -y is CommonJS). Both
snippets run as printed.
ESM ("type": "module", or .mjs):
import { initOperon } from "@operon/sdk";
// Defaults to https://api.operon.so - pass { url } to override.
const operon = initOperon();
// Your agent supplies the user's message; this stands in for it.
const userQuery = "cheapest way to swap 2 ETH to USDC?";
const result = await operon.getPlacement(userQuery, {
placement_context: "user asked about swapping ETH",
});
if (result.decision === "filled") {
console.log("Recommendation:", result.placement?.service);
}
// _meta carries sandbox/quota info. It can be absent - access with ?.
console.log(result._meta?.is_sandbox);CommonJS (default for npm init -y, no "type": "module"):
const { initOperon } = require("@operon/sdk");
// CommonJS has no top-level await - keep the calls inside an async function.
async function main() {
const operon = initOperon(); // defaults to https://api.operon.so
const userQuery = "cheapest way to swap 2 ETH to USDC?";
const result = await operon.getPlacement(userQuery, {
placement_context: "user asked about swapping ETH",
});
if (result.decision === "filled") {
console.log("Recommendation:", result.placement?.service);
}
console.log(result._meta?.is_sandbox);
}
main();TypeScript: full types ship with the package and resolve under
"strict": true with any modern moduleResolution (NodeNext, bundler).
Run the ESM snippet directly with npx tsx index.ts - no tsconfig
tweaks needed.
Once you've made a call, confirm the SDK is wired up correctly:
npx @operon/sdk test # fire one sandbox getPlacement
npx @operon/sdk status # show SDK state (UUID, source, registration)Both commands contact api.operon.so, and each counts as 1 call against
your sandbox quota - use them ad hoc, not in a health-check loop. Every
command supports --help (e.g. npx @operon/sdk register --help).
Register for production demand
After integrating, register to bump your sandbox quota and join the early-access list for production demand:
npx @operon/sdk registerregister is interactive. Scripted use works too - pipe one answer per
line (printf '[email protected]\nelizaos\nDeFi agent\n2\n' | npx @operon/sdk register);
if input ends before every prompt is answered, the CLI exits 1 with an
error instead of silently registering nothing. The prompts are email,
framework name, agent description, and a monthly call volume tier:
Operon Publisher Registration
Registering raises your sandbox quota from 100 to 1000 placements/hr
and puts you on the early-access list for production demand.
We'll only email you about quota changes and demand availability.
We'll use this to follow up about production demand pool access. No marketing spam.
Email: [email protected]
Help us prioritize which framework SDKs to ship next.
Framework (e.g. elizaos): elizaos
Help us understand who's building and what for.
What does your agent do? DeFi research agent
Used to assign appropriate quotas and prioritize early-access invites.
Expected monthly calls:
1) <1K (testing / hobbyist)
2) 1K-10K (small production agent) [default]
3) 10K-100K (mid-tier production)
4) 100K-1M (high-volume)
5) >1M (commercial)
Choice [2]:On success, the CLI echoes back your registration and the new quota:
Done. You're registered.
Email: [email protected]
Framework: elizaos
Agent: DeFi research agent
Expected volume: 1K-10K
Source: (not set, that's fine)
Status: registered
Sandbox quota: 1000 placements/hr (was 100)
Next steps:
- Try a placement (see https://operon.so/developers)
- Watch your impressions log
- Ready for production? Email [email protected] for your API key
(a one-line change: add apiKey to initOperon)API
initOperon(options)
| Option | Type | Description |
|--------|------|-------------|
| url | string? | Base URL of the Operon API. Defaults to the OPERON_URL env var when set, then https://api.operon.so. Must be non-empty when provided. |
| publisherName | string? | Display name for your agent. Defaults to operon-sdk. Max 200 characters. |
| apiKey | string? | Optional Bearer token. Only needed for authenticated publishers. Sandbox usage does not require a key. |
| timeoutMs | number? | Request timeout. Defaults to 10000. |
| source | string? | Marketplace/skill attribution tag ([A-Za-z0-9._-], max 64). First-touch wins: persisted on first valid call, ignored on later inits with a different value. |
| onRetryable | (err: OperonRetryableError) => void? | Observability hook fired when the server returns 429/503 + Retry-After, before the error is thrown. Use for metrics, Sentry, alerting, etc. Errors thrown by the callback are swallowed. Note that the SDK does not await the callback's return value - async callbacks should not throw; wrap async work in a try/catch internally. |
| maxRetryAfterMs | number? | Cap on Retry-After-derived backoff hints (ms). Defaults to 60_000. Invalid values (0, negative, NaN, Infinity) fall back to the default. |
getPlacement(query, context)
query is the user's natural-language input. The server caps query
(and every other string field it receives: category, asset,
amount, intent, sentiment, plus publisherName) at
200 characters, and actions must be an array of strings. The SDK
enforces all of this client-side: an empty query or an invalid field
throws instantly with an actionable message, before any network call -
no quota burned, circuit breaker unaffected. Truncate long user
messages before calling.
context must include placement_context (a one-sentence description
of what the user is doing, 1-1000 characters - the SDK enforces the cap
because the server silently drops longer values). Optional fields: asset, intent,
category, sentiment, amount, actions. intent is a hint;
when omitted, Operon classifies it from the query. Recognized values
include swap, buy, stake, bridge, compare, optimize,
rebalance.
Returns a placement response - see the next section. Inspect
_meta?.is_sandbox to know if you got mock demand.
Response fields
A filled response looks like this (sandbox example, trimmed):
{
"decision": "filled",
"reason": "...",
"placement": {
"sponsored": true,
"service": "Helix Exchange",
"category": "DeFi",
"description": "Multi-chain swap aggregator routing across 40+ DEXs.",
"bidPrice": 195,
"creative": "Helix Exchange routes your swap across 40+ DEXs for best execution.",
"clickUrl": "https://api.operon.so/c/imp_2cd29aefdb418a66",
"disclosure": null
},
"auction": { "candidates": 3, "eligible": 3, "winner": "Helix Exchange" },
"_meta": { "is_sandbox": true, "message": "...", "client_uuid": "..." }
}The fields a publisher acts on:
| Field | Meaning |
|-------|---------|
| decision | "filled" or "blocked". Blocked responses carry a reason string and no placement - they are a normal outcome, not an error. |
| placement.service | Advertiser/service name to mention in your response. |
| placement.creative | Ready-to-merge description text, when the advertiser provides one (null otherwise). |
| placement.clickUrl | Tracking link. Route the placement's link through this URL - linking the advertiser directly breaks click attribution, and unattributed clicks don't pay. |
| placement.sponsored | Always true on placements. Render your sponsorship disclosure from this, e.g. a "via operon" note in your response format. |
| placement.disclosure | Extra category disclosure text (e.g. age restrictions). null means none is required beyond your standard sponsorship note. Show it when present. |
| placement.bidPrice | Winning bid for the impression, in basis points of 1 USDC: divide by 10,000 for dollars (195 = about $0.02). |
| auction | Diagnostic detail about the auction round (candidates considered, winner). Useful for logging; treat it as informational, not stable API. |
| _meta | Sandbox and quota info: is_sandbox, message, client_uuid. Optional in the types because responses without a client UUID omit it - always access with ?.. |
Configuration
| Env var | Purpose |
|---------|---------|
| OPERON_CLIENT_ID | Override the persisted UUID. Use in CI or ephemeral environments without writeable home directories. Also disables source persistence so CI runs don't pollute developer-machine attribution. |
| OPERON_SOURCE | Override the attribution tag at request time. Doesn't write to disk. Use in CI/containers to test attribution behavior without affecting persisted state. |
| OPERON_URL | Default API URL for SDK calls and every CLI command when url isn't passed. Defaults to https://api.operon.so. The SDK requires https: for non-loopback values (a mutated env var would otherwise redirect requests, including your apiKey, to another host) and warns once when the override is active. Production publishers using apiKey should pass url explicitly. |
Sandbox vs production
Until production demand is live, all placements are sandbox. Check
_meta?.is_sandbox in responses. The request path, response shape, and
quota behavior are the same as production - only the demand pool is mock.
The first 100 calls per UUID return a register prompt. Past 100, the message upgrades to a quota nudge. Past 1000, it includes a personal note pointing to [email protected]. Registered users get 10x quota and a different message.
Errors
Everything getPlacement can throw, and what to do about it:
| Condition | What you catch | Notes |
|-----------|----------------|-------|
| Client-side validation: empty query, missing or over-long placement_context (1000 max), a field over 200 chars, bad actions | Error, message starts @operon/sdk: | Thrown before any network call - no quota burned, never counts toward the breaker. Fix the call site. |
| Throttled: rate limit (60 req/min) or hourly quota, HTTP 429 + Retry-After; or sandbox ceiling, HTTP 503 + Retry-After | OperonRetryableError with retryAfterMs and status | Wait and retry. Does not count toward the breaker. |
| Server rejected the request | Error, message starts Operon <status>: (400 validation, 429/503 without Retry-After, 500) | The server's reason is included. Counts toward the circuit breaker. |
| Timeout (timeoutMs, default 10s) | The platform's DOMException named TimeoutError | Counts toward the breaker. |
| DNS or connection failure | The platform's TypeError: fetch failed | The underlying code (e.g. ENOTFOUND) is on err.cause. Counts toward the breaker. |
| Circuit open | Error, message contains circuit breaker open | Thrown synchronously, no request made. Auto-recovers within 30s. |
Cross-build caveat: if your app mixes the ESM and CJS builds of this
package (for example an ESM app plus a CJS wrapper library),
instanceof OperonRetryableError can fail across the boundary because
each build has its own class identity. In mixed setups, check
err.name === "OperonRetryableError" instead.
Circuit breaker
Five failures within 30 seconds opens the circuit. While open, getPlacement throws synchronously without making a request. The window slides, so the circuit auto-closes once 30 seconds pass without new failures.
What counts as a failure: network errors, timeouts, and non-2xx
responses - including 400s, so validate field lengths client-side to
keep bad input from opening the circuit. What doesn't count: 429s and
503s carrying Retry-After (OperonRetryableError), client-side
validation errors, and breaker-open rejections themselves.
Handling throttling (429 / 503)
When the server throttles - HTTP 429 for the per-key rate limit (60
requests/min) or the hourly sandbox quota (100/hr unregistered,
1000/hr registered), or HTTP 503 for the sandbox-lane global ceiling -
and includes a Retry-After header, the SDK throws
OperonRetryableError carrying retryAfterMs and status. These do
NOT count toward the circuit breaker.
Note: retryAfterMs is capped at maxRetryAfterMs (default 60s), but
the hourly quota sends Retry-After: 3600 - treat retryAfterMs as a
minimum wait, not the full window. A 429 quota error means the hour's
budget is spent.
import { initOperon, OperonRetryableError } from "@operon/sdk";
const op = initOperon();
async function placementOrNull(query: string, ctx: { placement_context: string }) {
try {
return await op.getPlacement(query, ctx);
} catch (err) {
if (err instanceof OperonRetryableError) {
// Server asked us to wait; e.g. skip this placement and continue
console.log(`throttled, retry after ${err.retryAfterMs}ms`);
return null;
}
throw err;
}
}Observability hooks and backoff tuning
Use onRetryable to pipe throttle events into your metrics or alerting stack. The hook fires before the throw - your try/catch still runs normally. Use maxRetryAfterMs to override the default 60s cap on Retry-After hints (useful when your loop budget is tighter):
import { initOperon, OperonRetryableError } from "@operon/sdk";
const op = initOperon({
// Fired when 503 + Retry-After comes back. Doesn't gate the throw -
// your try/catch still runs. Use this for metrics, alerts, etc.
onRetryable: (err) => {
metrics.increment("operon.retryable", { retryAfterMs: err.retryAfterMs });
},
// Override the default 60s cap on retry hints.
maxRetryAfterMs: 30_000,
});