TypeScript SDK

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 })
  • step names the step, and it is what the dashboard shows.
  • The call's model is your frontier model: what it uses whenever no approved route says otherwise. wrap hands 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/model strings, such as OpenRouter, pass one function instead of a mapping.
  • wrap reads the call's messages, tools, Anthropic's system and the end-user field (user or metadata.user_id); only hashes of them are sent.
  • A Responses call that continues a stored conversation (previous_response_id or conversation) 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,
})
  • step names the step. A function is called for each model call, so one provider can serve several steps. Without step, 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 for claude-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: true and use createOpenAI({ baseURL, fetch }) with .chat("anthropic/claude-opus-5").
  • @ergoda/sdk/ai imports neither ai nor 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 routed fetch and 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 (previousResponseId or conversation in providerOptions.openai). It carries only its newest turns, so the conversation it would be judged in is not there; wrap sends 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/dir moves it; ERGODA_SPOOL_DIR=off turns 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 the high level;
  • a reply contract whose id_patterns or next_action_pattern uses 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 model or step, since nothing checks the answer you get back. Use wrap on a checked route;
  • every checked step of a client that can't compute a sha256: off Node with no sha256 option passed, or with a digestKey and 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 prices you 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_s isn'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, wrap sends contents exactly 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.