The TypeScript SDK, @ergoda/sdk, decides which model each step of your agent
uses, and reports metadata about what happened. Your provider SDK and keys
stay yours.
Requires Node.js 22.18 or newer.
Install
npm install @ergoda/sdk
Get a key
Open an account at https://app.ergoda.com/onboarding; that is the only place one is made. Its Connect step, under TypeScript or a server, shows an agent key once, with the three variables to set where the agent runs:
ERGODA_URL=https://api.ergoda.com
ERGODA_KEY=hk_... # an agent key: it sends events and fetches policy, nothing else
ERGODA_SITE=... # your site ID
Put them in your server's or platform's environment, never in source. Outside
production, also set ERGODA_ENVIRONMENT to staging, development or
eval, or pass environment to new Client; only production traffic counts
as evidence (environments), and any other word throws when the
client is built. Both come after 0.1.5: @ergoda/sdk 0.1.5 and earlier have
no environment option, ignore ERGODA_ENVIRONMENT and report every row as
production. Later keys are made under Keys on the dashboard. If Python is on the machine,
pip install ergoda and ergoda login write the same three to ergoda.env.
Wrap a call
Build the client once, then wrap the call your agent already makes. The arguments and the response stay exactly as they were:
import Anthropic from "@anthropic-ai/sdk"
import { Client } from "@ergoda/sdk"
const ergoda = new Client({
controlPlaneUrl: process.env.ERGODA_URL!,
siteKey: process.env.ERGODA_KEY,
siteId: process.env.ERGODA_SITE!,
defaultModel: "anthropic/claude-opus-5",
})
const anthropic = new Anthropic()
const create = ergoda.wrap(
{ anthropic: anthropic.messages.create.bind(anthropic.messages) },
{ step: "resolve_ticket" },
)
// unchanged from here: same arguments, same response
const reply = await create({ model: "claude-opus-5", max_tokens: 1024, messages })
With OpenAI, or any API that speaks its format:
import OpenAI from "openai"
import { Client } from "@ergoda/sdk"
const ergoda = new Client({
controlPlaneUrl: process.env.ERGODA_URL!,
siteKey: process.env.ERGODA_KEY,
siteId: process.env.ERGODA_SITE!,
defaultModel: "openai/gpt-5.6-sol",
})
const openai = new OpenAI() // or new OpenAI({ baseURL }) for any compatible API
const create = ergoda.wrap(
{ openai: openai.chat.completions.create.bind(openai.chat.completions) },
{ step: "resolve_ticket" },
)
// unchanged from here: same arguments, same response
const reply = await create({ model: "gpt-5.6-sol", messages })
stepnames the step, and it is what the dashboard shows.- The call's
modelis your frontier model: what it uses whenever no approved route says otherwise.wraphands each provider's client its bare model id, and skips a route to a provider you gave no client for. - For a gateway that takes
provider/modelstrings, such as OpenRouter, pass one function instead of a mapping. wrapreads the call'smessages,tools, Anthropic'ssystemand the end-user field (userormetadata.user_id); only hashes of them are sent.- A Responses call that continues a stored conversation (
previous_response_idorconversation) carries only its newest turns, so the checks could not see the conversation they would judge. It goes to the provider as written, on your model, and is not recorded, as in the Python SDK and the framework adapters.
Frameworks
The Vercel AI SDK and Mastra make the provider call themselves, so there is no
create to wrap. Give the provider a routed fetch instead. Every model call
it makes then goes through wrap, so tool loops, streams and agents work as
before, and the model you name is your frontier:
import { createAnthropic } from "@ai-sdk/anthropic"
import { generateText } from "ai"
import { Client } from "@ergoda/sdk"
import { routedFetch } from "@ergoda/sdk/ai"
const ergoda = new Client({
controlPlaneUrl: process.env.ERGODA_URL!,
siteKey: process.env.ERGODA_KEY,
siteId: process.env.ERGODA_SITE!,
defaultModel: "anthropic/claude-opus-5",
})
const anthropic = createAnthropic({ fetch: routedFetch(ergoda, { step: "resolve_ticket" }) })
const { text } = await generateText({
model: anthropic("claude-opus-5"),
maxOutputTokens: 4096,
tools,
prompt,
})
createOpenAI takes the same fetch, for the Responses API
(openai("gpt-5.6-sol")) and for Chat Completions (openai.chat(...)).
streamText and structured output (Output.object) need nothing more.
A Mastra agent takes the same model:
import { Agent } from "@mastra/core/agent"
const agent = new Agent({
id: "support",
name: "Support",
instructions,
model: anthropic("claude-opus-5"),
tools,
})
stepnames the step. A function is called for each model call, so one provider can serve several steps. Withoutstep, the step is found from the shape of the conversation.- Set
maxOutputTokens. Without it the AI SDK asks for the most your model can write (128,000 tokens forclaude-opus-5), and a cheaper model with a lower limit refuses the request. The request is built for the model you name: anything in it a cheaper model does not accept comes back as that provider's 400, which reaches you and is not retried on your frontier. - A provider error reaches the AI SDK as the provider sent it, so its own errors and retries apply. A 408, 409, 429, 5xx or dropped connection from the cheaper model is first sent to your frontier, as under
wrap. - A route to a model of another provider is not taken: the step uses your own model, as under
wrap. - For a gateway whose model ids name their provider, such as OpenRouter, pass
gateway: trueand usecreateOpenAI({ baseURL, fetch })with.chat("anthropic/claude-opus-5"). @ergoda/sdk/aiimports neitherainor any provider package; it works with the ones you have.
These calls are sent as written, on your model, and not recorded:
- A model given as a string (
model: "anthropic/claude-opus-5"). The AI SDK serves it through Vercel's AI Gateway provider, which has no routedfetchand speaks the AI SDK's own format, not a provider's. Build the provider yourself, as above. - A provider that puts the model in the URL, such as
@ai-sdk/google, Bedrock and Vertex. - A Responses call that continues a conversation stored with OpenAI (
previousResponseIdorconversationinproviderOptions.openai). It carries only its newest turns, so the conversation it would be judged in is not there;wrapsends the same call as written. - Anything that is not a model call: files, embeddings, token counts.
By hand
When the call is not a single provider function, resolve the step, make the call, and report it:
const step = await ergoda.step("resolve_ticket", { messages, tools, caller: userId })
const res = await myProvider.complete({ model: step.model, messages, tools })
step.done({ usage: { input_tokens: res.usage.input, output_tokens: res.usage.output } })
When the whole task is over, report how it ended, however late:
// "success", "failure" or "unknown"
step.taskOutcome("success", { source: "ticket_closed" })
// by the task's messages
ergoda.taskOutcome(messages, "failure")
The task is named by the trajectory digest its steps carried (a Step, a
Choice, the messages, or the 64-hex digest); your own task id throws rather
than being sent, as does an outcome outside the three. Send unknown when you
could not tell: no report reads as pending, then missing, never as a failure.
The cost per successful task report reads
these.
Declaring a caller
Say which of your customers a request is for, and the dashboard's Customers
page and GET /v1/sites/{site}/customers show cost per successful task for
each of them. wrap reads it from the request's own field (user or
safety_identifier, or Anthropic's metadata.user_id); by hand, pass
caller. Only a digest of the id is sent, salted with your site id and keyed
with digestKey (ERGODA_DIGEST_KEY by default) when you set one. To name
the report's rows, digest your ids the same way:
const digest = ergoda.callerDigest(customerId) // the caller_sha256 the report shows
Or run ergoda customers --ids customers.txt, which does the same on your
machine and prints the report with your ids in it.
Serverless
Build the client at module scope, so the policy and the event buffer outlive one invocation. A platform that freezes the process between invocations also freezes the timer that sends events, so hand the flush to the platform:
import { waitUntil } from "@vercel/functions" // Cloudflare: ctx.waitUntil
export async function POST(req: Request) {
const res = await agent(req)
waitUntil(ergoda.flush()) // after the response; never rejects
return res
}
With no waitUntil, as on AWS Lambda, wrap the handler's body instead:
export const handler = (event) => ergoda.request(() => agent(event))
request waits for the control plane at most 1.5 seconds, one attempt, so a
control plane that hangs adds that much to an invocation and no more. What it
did not deliver stays in memory for the next invocation. (Before 0.1.1 it
waited out the 10-second request timeout every time.)
A serverless function usually has no writable home directory, so it has no
disk spool (below): events refused during a billing hold stay in memory, at
most 1,000, and a cold start loses them. On a platform that keeps /tmp
between warm invocations you can set ERGODA_SPOOL_DIR=/tmp/ergoda-spool; a
cold start still loses it. An edge runtime has no spool at all.
A script, a job, a worker
A timer sends events every two seconds, so a short script can end before its
last events leave. When Node's event loop empties, the client makes one
best-effort flush, bounded at 1.5 seconds, and prints one line with
console.warn if anything was left. That hook does not run when the process
ends by process.exit(), a signal such as a deploy's SIGTERM, or an uncaught
exception. On those paths, and wherever you can, end with:
await ergoda.close() // delivers the tail, waits at most 5 seconds
close() makes one attempt per batch. (It first waits up to 30 seconds for a
comparison still in flight, a candidate model or prompt tried on the same
step, so the 5 seconds is for delivery, not the whole call.) If the control
plane does not take them, the events are written to the disk spool and
console.warn says how many; with no spool they are dropped with the client
and console.warn says how many and why. It does not retry in the background.
The disk spool. On Node, events the client could not deliver go to disk in
two cases: when the control plane answers with a billing hold (HTTP 402, see
billing), at once and for the whole queue, and at
close() or the exit flush with events left. The next client for the same
site and control plane, in this process or a later one, sends them first,
oldest first, and deletes each file once the control plane has taken it. While
a hold stands a client sends one batch a minute to find out whether it has
lifted, not the backlog. In between it writes each batch to disk as it
fills and keeps only the partly filled one in memory, so a lifted hold is
noticed up to a minute late. The Python SDK writes the same files to the same
directory, so either can send what the other left.
- Where.
$XDG_STATE_HOME/ergoda/spool, else~/.local/state/ergoda/spool, one directory per site and control plane.ERGODA_SPOOL_DIR=/some/dirmoves it;ERGODA_SPOOL_DIR=offturns it off. The directory is created mode 0700 and each file 0600; a directory that another user owns or could write turns the spool off, since every file in it is sent with your key. - What. Each file is one event batch exactly as it would have been sent.
- How much. At most 100,000 events, 64 MB and 30 days (
HOLD_SPOOL_MAX_EVENTS,HOLD_SPOOL_MAX_BYTES,HOLD_SPOOL_MAX_AGE_MS). Past any of the three the oldest files are deleted first and counted as dropped. - Sent twice. Ingest keeps one copy of a batch id and of each event id, so a file sent twice counts once.
- Where it stops. No spool off Node, and none where the directory cannot be written: the first failed write turns it off for the process. The exit write runs where the exit flush runs, so
process.exit()and signals skip it; under a hold every full batch is already on disk by then, and only the partly filled one in memory is at risk.
The cost ceiling, and your prices
New in 0.1.1. @ergoda/sdk 0.1.0 has no cost ceiling and no prices
option; npm install @ergoda/sdk@latest for both.
Each routed step is charged to its route's bill, as in the Python SDK, and a
route that has cost more than your frontier alone would have (5% more by
default, the route's max_cost_ratio) serves your frontier until its bill
recovers. The bill is in dollars when a step's usage can be priced, and this
SDK carries no price table, so state the prices of the models you use:
const ergoda = new Client({
controlPlaneUrl: process.env.ERGODA_URL!,
siteKey: process.env.ERGODA_KEY,
siteId: process.env.ERGODA_SITE!,
defaultModel: "anthropic/claude-opus-5",
prices: {
"anthropic/claude-haiku-4-5": { input_per_million_usd: 1, output_per_million_usd: 5,
cache_read_per_million_usd: 0.1, cache_write_per_million_usd: 1.25 },
"anthropic/claude-opus-5": { input_per_million_usd: 5, output_per_million_usd: 25,
cache_read_per_million_usd: 0.5, cache_write_per_million_usd: 6.25 },
},
})
Without prices it reads the prices your routing policy declares, then the
route's own price ratio; a route none of them can judge is never sent back on
a guess, and it says so once with console.warn.
Write validators
If your backend can answer "would you accept this call?" without making it (a
dry-run flag or a validation endpoint), give the client a function per tool
and name the tools in the route's validated_tools, as with the Python SDK's
Client(validators=...):
const ergoda = new Client({
// ...
validators: {
refund_order: async (args) => {
const res = await fetch("https://billing.internal/refunds/validate", {
method: "POST",
body: JSON.stringify(args),
signal: AbortSignal.timeout(2000),
});
const answer = await res.json();
return answer.ok ? true : answer.reason; // true accepts; false or a reason refuses
},
},
})
The validators helpers build the same thing in a line. ownedBy checks
that the signed-in user owns the record the call acts on, which in our retail
tests stopped every write the model made on another customer's account, and
http asks your endpoint. Your endpoint answers {"ok": true} (or {"valid": true}, or an empty 204) to accept, and {"ok": false, "reason": ...}, an error, or a 4xx such as 422 to refuse. Anything else, including a redirect, a 5xx, a 401/403/404/429, a timeout or a body that says neither, is no verdict, and the write goes to your frontier. The same answers mean the same
thing to fromCallable:
import { validators } from "@ergoda/sdk"
const check = validators.allOf(validators.ownedBy((a) => orders.ownerOf(a.order_id)), validators.http("https://billing.internal/refunds/validate", { dryRunParam: "dry_run", timeoutMs: 2000 }))
const ergoda = new Client({ /* ... */ validators: { refund_order: check } })
Run each conversation inside validators.actingFor(userId, () => ...). With no
user set, ownedBy gives no verdict. fromCallable(fn, { refuseOn: [MyError] })
wraps a validate function you already have.
A validator can be a plain function or an async one. wrap asks it inside
the free checks, after they pass, at most once for each tool call the cheaper
model proposes, and waits for the answer before it serves anything. A refusal
sends the step to your frontier, and under decide the graduation census
does not count it against the tool. Anything else sends the step to your
frontier too, and the census counts it as before: a value other than true,
false or a non-empty reason, a throw, a rejected promise, or a validated
tool with no function. The SDK sets no timeout of its own, so bound the call
yourself as above: a request that times out rejects, and a rejection is no
verdict. The function gets a copy of the arguments in plain values. The
reason stays in your process and is never sent.
@ergoda/sdk 0.1.8 and earlier ignore validated_tools and
confirmed_tools. While one of them sends events, autopilot graduates nothing
on a route that names either and takes back what that route had graduated.
Tracing
If your service traces with OpenTelemetry, each recorded step adds one span,
ergoda.step, as a child of the active span, covering the step's time. It
carries the attributes the Python SDK writes, under
the same names: the model asked for and the model that answered
(gen_ai.request.model, gen_ai.response.model), ergoda.tier (cheap or
frontier), whether and why the step escalated, the route and policy, the
environment, the cost when known, the event id, and error.type with an ERROR
status when the step failed. A step wrap checked also carries the gate's
attributes, read off the rows of the proposal that decided it, as in Python:
ergoda.gate.layers, ergoda.gate.refused_by and ergoda.gate.confidence
(below). It has no
ergoda.sample_reasons, because a shadow is drawn after the step is recorded. The span your instrumentation
writes for the provider call sits beside it, not inside it.
No prompt, output, tool name, caller id or token count is on it: usage is already on your call's own span, and repeating it would count the step twice. Langfuse records it as a span, not a generation; Datadog LLM Observability shows it as a workflow span.
@opentelemetry/api is an optional peer dependency. The SDK never loads it at
import, and loads it at all only once your process has registered
OpenTelemetry's context manager, which any tracing setup does; it writes the
span only when a recording span is active. Without the package, or off Node,
nothing is written. Tracing comes after 0.1.5.
How it differs from the Python SDK
wrap checks the cheaper model's answer before you get it, as the Python SDK
does. On a route that declares a check, the cheaper model answers first and
the SDK checks every tool call and reply in that answer, in your process. If
the checks pass you get that answer; if they refuse it, your frontier model
answers the same call and you get that instead. These are the Python SDK's
free checks, held to its verdicts by a shared test table: the tool catalogue,
repeated calls and stalled loops, argument, reply and structured-answer
contracts, grounding in what your tools returned, the recoverable set and the
decided sign-off, the divergence budget, the confidence bar a route was
licensed at, and the sampled check of tools you declared recoverable. When a
check refuses something the cheaper model could fix, such as a write before
the read it needs, the cheaper model is asked once more with the reason, and
your frontier answers if it is refused again. Set the
assurance level with assurance on the client or ERGODA_ASSURANCE;
decided is the default, as in Python. @ergoda/sdk 0.1.5 and earlier check
nothing.
Where this SDK can't run a check the route relies on, your frontier model
answers before the cheaper model is asked, recorded with the reason
gate_unavailable. That happens for:
- a route with a paid check (
self_consistency,frontier_adequacy,reply_verifier), which includes every checked route under thehighlevel; - a reply contract whose
id_patternsornext_action_patternuses Python regex syntax this SDK doesn't translate (inline flags such as(?i), backreferences, conditionals, atomic groups, possessive quantifiers); - a streamed call on a checked route;
- a step you resolve yourself with
modelorstep, since nothing checks the answer you get back. Usewrapon a checked route; - every checked step of a client that can't compute a sha256: off Node with no
sha256option passed, or with adigestKeyand no Node to compute the keyed digest. A proposal it can't digest can't be named on the record.
routedFetch hands each call to wrap, so the Vercel AI SDK and Mastra get
the same checks, and streamText on a checked route is a streamed call.
The rest of the differences:
- A decision class the route has graduated is still your frontier's. Python lets the cheaper model make it and checks a share of those decisions against your frontier; that check is a model call this SDK doesn't make.
- Both SDKs skip the cheaper model on a step they predict your frontier will have to take, once the route's own steps make that likely enough to cost more than it saves. Python reads that price point from its own price table; this SDK reads the
pricesyou give it, then your policy's declared prices, and assumes a 12:1 ratio without either. A skipped step is your frontier's; a step that isn't skipped is checked. - A route's
proposal_deadline_sisn't applied. Python stops waiting for the cheaper model at the deadline and asks your frontier; this SDK waits for the cheaper model's answer. - On a Gemini call,
wrapsendscontentsexactly as you wrote them, so the retry's extra turn can't be added: this SDK sends a fixable refusal straight to your frontier, where the Python SDK currently raises an error. - Python asks your frontier a second time on a sampled share of refused steps (
frontier_twin_rate), to see how often it disagrees with itself. That comparison licenses nothing, and this SDK doesn't make it.
A route that declares no check is served as approved, as in Python.
When Ergoda can't be reached
Your calls keep running. A missing, stale or unreachable policy always resolves
to your defaultModel, never to a cheaper model. Events are batched and sent
on a timer, and sending never throws; a key the control plane refuses is
reported once with console.warn. With controlPlaneUrl or siteId unset
the client runs unmeasured: every step uses defaultModel, nothing is sent,
and it says so once.
The policy is fetched again every five minutes, and sooner when a reply to an
event batch names a newer one. A reply that is not a valid policy (a 200 with
no policy, say) is ignored: the last good policy stays in force and
ergoda.policies.lastError says why.
The last good policy is served through an outage, but not for ever. After 24
hours with no answer from the control plane (a refused key, an error or a
hang all count as no answer), every step uses your defaultModel and the
client says so once with console.warn, until a fetch succeeds. That is what
lets a revocation or an expiry reach a process that has lost its connection.
Set policyMaxAgeMs on the client to change the 24 hours, or to 0 to serve
the last good policy for ever. The Python SDK does the same
(ERGODA_POLICY_MAX_AGE_S).
The TypeScript SDK has no local policy file: a new process that cannot reach
the control plane serves your defaultModel until it can. Routing with no
control plane at all is the Python SDK's or the proxy's; see
concepts, "If Ergoda is unreachable, or goes away". What the
Python SDK and the proxy add to a step's time is under "Latency" on the
same page; the TypeScript SDK's own overhead has not been benchmarked.
For what leaves your process and what never does, see Security and data.