Cendor for AI coding assistants

You probably arrived here because you told an assistant — Copilot, Claude Code, Cursor, Windsurf — “add cost budgeting to my OpenAI calls” and it reached for Cendor. Cendor is new, so a model that hasn’t seen much of it will guess the call-shape, and a few of our shapes are non-obvious (budget(cfg)(fn) is curried in TypeScript; prices.estimate is positional in Python; the SQLite session store is spelled differently in each language). This page is the canonical call-shape reference — a trap table your assistant (or you) can read once, then get every call right.

You don’t have to read this to use Cendor correctly, though. Every public symbol ships an inline @example and a one-line correct-shape in its type signature, so your editor’s language server (Pylance / tsserver) — and any agent-mode assistant that reads diagnostics — is handed the right shape at the moment you type the call. The wrong shape is a compile error whose message states the right one. This page just makes that knowledge copy-pasteable.

How to point your assistant here. Paste this page’s URL (or the trap table below) into your assistant’s context, or drop the trap table into your repo’s AGENTS.md / .github/copilot-instructions.md / .cursor/rules. The types teach the rest on install.

Or run one command. npx @cendor/init (Node) / uvx cendor-init (Python) writes the rules files into your repo for you — idempotently, never clobbering your own content — and can add the MCP config and a working starter. Offline, no key. It ships a doctor too: npx @cendor/init doctor static-checks your wiring (namespace, provider deps, instrument() once, money-as-Decimal, versions) and exits non-zero on hard problems, so it fits CI.

The trap table

Every row is verified against the current source in both languages. snake_case (Python) ↔ camelCase (TypeScript) is the default rename; the traps below are where the shapes genuinely differ or where a plausible guess is wrong.

TaskPython (cendor.*)TypeScript (@cendor/*)The trap
Instrument a clientclient = instrument(OpenAI())const client = instrument(new OpenAI())Wrap the client once, not per call. Idempotent, additive, and works on sync / async / streaming clients.
Budget a function@budget(usd=0.50, on_exceed="raise") — or with budget(usd=0.50) as b:budget({ usd: 0.50, onExceed: 'raise' })(fn) — or withBudget(cfg, cb)TS budget is curried: budget(cfg)(fn), never budget(cfg, fn). Python budget(...) is not curried — it takes keyword args and is itself a decorator and a context manager (there is no with_budget).
on_exceed / onExceed"raise" | "block" | "truncate" | "downgrade" | "clamp" | "break" (or a callable)same set (or a callable)A fixed union — a typo is a type error, not a silent no-op. "break" (tg ≥ 1.5 / 0.6) is the mid-stream one — see the next row.
Mid-stream break (streaming runaway)with budget(tokens=N, on_exceed="break"): …withBudget({ tokens: N, onExceed: 'break' }, cb)"break" cuts a streamed call mid-flight the instant its running output estimate (visible text + visible thinking) crosses the remaining tokens=/usd= budget. You keep the partial output already yielded; the provider still bills the tokens it generated up to the cut (~one chunk + one RTT past — it stops the meter, it does not un-bill the provider), and the settled usage is an estimate flagged usage_estimated. Hidden reasoning (OpenAI-native/Gemini) never streams, so a heavy-thinking model cuts late unless you raise reasoning_reserve. Needs core ≥ 1.10 / 0.11 (the stream-observer seam).
Estimate costprices.estimate(model, input_tokens, output_tokens=200)prices.estimate(model, inputTokens, { outputTokens: 200 })Python takes output_tokens positionally; TS requires the { outputTokens } options object. Real divergence — don’t cross them.
Register a model priceprices.register_model_price(model, input=…, output=…, per="1M") — from cendor.core (>= 1.15.0), or prices.register(model, {"input": …, "output": …}) for per-tokenprices.register(model, { input, output }) per-token, or prices.registerModelPrice(model, { input, output }) per-1M — both from @cendor/coreBoth doors, both languages — per-token since core 1.15.0 / @cendor/core 0.6.0, and the per-1M convenience since core 1.15.0 / @cendor/core 3.4.0 — before that Python had no public prices.register and a libraries-door user needed the cendor-sdk distribution for one function. cendor.sdk.register_model_price still works and is now a thin re-export. Before 3.4.0 the TypeScript per-1M form lived only in @cendor/sdk, so a libraries-door TS app that imported it from @cendor/core got nothing; @cendor/sdk’s twin still works. Watch the units: register is per-token; the …_model_price / registerModelPrice helpers default to per-1M (per="1K"/"token" to change), and confusing them is a 1,000,000× cost error. Registrations survive prices.refresh(). For an Azure deployment name you usually want the next row instead — you know the model it serves, not its rate card.
Price a Microsoft Foundry deployment nameprices.register_deployment("prod-gpt4o-eastus", like="gpt-4o")prices.registerDeployment('prod-gpt4o-eastus', { like: 'gpt-4o' })On Azure the id a call reports is the deployment name the user chose, so it is in no price table: cost is None/null, tokenguard records $0, and a USD budget silently never binds. This maps it onto a base model’s rates explicitly — an unknown like raises rather than leaving it quietly unpriced. Copy-at-registration: a later refresh() that reprices the base does not reprice the deployment (call it again). Cendor never guesses a price from an id’s shape — -preview/-latest auto-aliasing was considered and rejected. ⚠️ For most non-OpenAI Foundry models there is no base to copy: the bundled snapshot has no DeepSeek / Mistral / Phi rows, so like= raises — use register_model_price(dep, input=…, output=…) (USD per 1M) with the rate card instead. Also re-exported from both SDKs.
Count tokenstokens.count(messages, model="gpt-4o")tokens.count(messages, 'gpt-4o')It’s tokens.count, not count_tokens / countTokens. Counts match across languages (tiktoken / js-tiktoken).
Pre-call interceptoradd_interceptor(fn) / remove_interceptor(fn) (top-level of cendor.core)addInterceptor(fn) / removeInterceptor(fn)Not on busbus only has subscribe / unsubscribe / emit. Return a Reroute(...) from an interceptor to rewrite the call before it’s sent.
MoneyMoney(Decimal("0.01")) — never floatnew Money(new Decimal('0.01'))decimal.js, never numberCost / price values are Decimal / decimal.js. A float/number is a precision bug, and money-typed params reject it.
Assemble contextmsgs = Context(budget_tokens=8000, model="gpt-4o").assemble() (sync; async is aassemble())const msgs = await new Context({ budgetTokens: 8000, model: 'gpt-4o' }).assemble() (async)assemble() is sync in Python (separate aassemble() for async) but async in TS (no sync form). It’s a method on Context, not a free function.
Compress-to-fit blocksBlock(docs, evict="compress") needs the contextkit[squeeze] extranew Block(docs, { evict: 'compress' }) needs @cendor/squeeze installedWithout squeeze present, evict="compress" silently falls back to truncation.
Compress a payloadsmall, handle = compress(content, kind="auto", fidelity="balanced")const [small, handle] = compress(content, { kind: 'auto', fidelity: 'balanced' })kindauto|json|logs|code|prose; fidelitylossless|balanced|aggressive. Returns a (small, handle) pair — keep the handle to expand().
(De)serialize a handlehandle.to_dict() / Handle.from_dict(d)handle.toDict() / Handle.fromDict(d)snake_case in Python, camelCase in TS (the wire keys inside the dict stay snake_case).
Swap the compression storefrom cendor.squeeze import storeuse_store(store.SQLiteStore(path))import { SQLiteStore, useStore } from '@cendor/squeeze'Store classes are capital-SQL SQLiteStore / MemoryStore; in Python reach them via cendor.squeeze.store.* (not top-level).
Deterministic guardrailrules.keyword_deny([...], action="block")rules.keywordDeny([...], { action: 'block' })Names: regex_rule/regexRule (not regex), custom (not custom_rule), plus custom_category, intent, denied_topics. PII/secrets are not guardrails rules — they’re acttrace detectors (bridged into the SDK as rules.pii/rules.secrets).
Guardrail action / stagesaction="block" | "redact" | "flag"action: 'block' | 'redact' | 'flag'Four stages: input, tool_call, tool_output, output. There is no warn.
Run a gate directlypayload, decisions = evaluate(gate, "input", text); catch GuardrailTrippedconst { payload, decisions } = evaluate(gate, 'input', text); catch GuardrailTrippedUnder the SDK you do not call evaluate yourself — pass Agent(guardrails=[…]) and the loop gates all four stages.
Local semantic embedderembeddings.local_embedder() ([embeddings] extra)await embeddings.localEmbedder() (async; @huggingface/transformers peer)It’s embeddings.localEmbedder, not rules.localEmbedder.
Record / replay a run@cassette.use("t.json") (decorator) — or with cassette.using("t.json"):cassette.use('t.json') (decorator) — or await cassette.using('t.json', async () => …)use is the decorator, using is the scope form. modeauto|record|replay|rerecord.
Session store (SDK)SQLiteSessionStore(path) — capital SQLitenew SqliteSessionStore(path)SqliteCasing differs across languages. It lives in the SDK, not cassette — cassette has no session store.
Audit + export evidenceAuditLog(system="support", risk_tier="limited"); audit.export(path, framework="eu_ai_act")new AuditLog('support', { riskTier: 'limited' }); audit.export(path, 'eu_ai_act')export / verify hang off the log. frameworkeu_ai_act|gdpr|iso_42001|nist_rmf. There is no top-level decisions — group work with AuditLog.decision().
Spend sink subpath (tokenguard)from cendor.tokenguard import sinkssinks.SQLiteSink(path)import { SQLiteSink } from '@cendor/tokenguard/sinks'In TS the sinks live at the /sinks subpath, not the package root.
Export to an OTel backendconfigure an OTel pipeline in your app (e.g. configure_azure_monitor(...)), then use_sink(sinks.OTelSink()) + live_spans(label="nightly sweep") + AuditLog(mirror=OTelMirror()); name budgets — budget(usd=5, name="per-run cap")same: useSink(new OTelSink()) + liveSpans({ label: 'nightly sweep' }) + new AuditLog(s, { mirror: new OTelMirror() }); budget({ usd: 5, name: 'per-run cap' })Cendor exports, never collects — it emits standard gen_ai.* into the global OTel provider you configure (Azure Monitor / CloudWatch / Datadog / OTLP), with no Cendor-specific exporter. There is no endpoint=/connection_string= on any Cendor call. budget(name=…) (tg ≥ 1.3 / 0.4) surfaces as cendor.audit.budget so a monitor shows which budget acted; label= (sdk ≥ 1.10 / 0.14) stamps cendor.run.label — a chosen tag, never the prompt. Prompt/response content stays off the wire unless you opt in with otel.capture_content() (core ≥ 1.7 / 0.7) — see the content-capture row.
Audit mirror ≠ the evidenceAuditLog(mirror=OTelMirror())verify() still runs on the filenew AuditLog(s, { mirror: new OTelMirror() })The mirror is an operational copy for APM/SIEM (monitoring/alerting). The hash-chained file (or a signed export() pack) is the only thing verify() checks — never claim the mirror is the tamper-evident record. A failing mirror is swallowed, never breaking the chain.
Cendor Monitor is optional/self-hosteddocker run … ghcr.io/cendorhq/cendor-monitor; the app only sets OTEL_EXPORTER_OTLP_ENDPOINTsame — a standard OTLP env var, no Cendor SDK settingCendor Monitor is an optional, self-hosted container (dev tooling like cendor-mcp) — not a hosted service, not a library dependency, not a Cendor telemetry endpoint. Apps connect via standard OTel env vars, never a Cendor API or key; the documented default stays your own backend (Azure Monitor / CloudWatch / Datadog / OTLP). Its governance board is an operational copy — verify() runs on the file.
Capture prompts/responses (content)otel.capture_content(mask=…, max_bytes=8192) — or env OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=trueotel.captureContent({ mask, maxBytes }) — same env varContent capture is OPT-IN and OFF by default — a monitor never enables it for you. On, prompts/responses/thinking/tool values ride the semconv content span attrs (gen_ai.input.messages / gen_ai.output.messages / gen_ai.system_instructions; tool lane cendor.tool.arguments/.result), masked (fail-closed) + byte-capped. Content never enters the acttrace chain or its OTelMirror (rule 6), and it lands only where your OTLP goes (Cendor never receives it).
A governed agent (SDK)Agent(name=…, model=…, guardrails=[…], max_usd=0.5); run(agent, "hi")new Agent({ name, model, guardrails: [...], maxUsd: 0.5 }); run(agent, 'hi')No budget= field on Agent — the per-agent cap is max_usd/maxUsd; process-wide budgets use tokenguard’s budget(). TS ships OpenAI + Anthropic first-class; other providers construct lazily.
Provider API key (SDK)Agent(api_key=…) — or the provider’s standard env varnew Agent({ apiKey: … }) — or the same env varThe SDK builds the provider client, so there is no Cendor key config. Keys resolve api_key/apiKey → the provider’s standard env var (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY, the AWS credential chain for Bedrock) → a keyless placeholder that 401s on a live call. Never invent a CENDOR_* key; a pre-built client= also works.
SDK guard vs acttrace guardwith guard(policy=Policy.strict(), audit=log): … — scope form; raw interceptor form: itc = guard(policy) then add_interceptor(itc)await guard({ policy, audit }, async () => …) — scope form; raw interceptor form: const itc = guard(policy) then addInterceptor(itc)One object, two shapes. sdk.guard is acttrace.guard (identical since cendor-sdk 1.7.0 / @cendor/sdk 0.10.0, on acttrace ≥ 1.5.0 / 0.6.0 — its return is dual-shape). The scope form (Python with, TS (opts, fn)) installs the interceptor on core’s seam and removes it on exit; a bare guard(policy, audit) call only builds the raw interceptor — it gates nothing until installed with add_interceptor/addInterceptor.
LLM-judge intent / adherence (SDK)from cendor.sdk import judge, rules; judge.task_adherence(respond) / judge.intent_prompt(i, mode="deny")judge.taskAdherence(respond) / judge.intentPrompt(i, 'deny')judge.* build an LLM-judge check (a policy string or a verdict fn), not a guardrail — wire via rules.llm_judge(check, stage=…). Both are re-exported on the SDK surface.
cassette in the SDKfrom cendor import cassettenot from cendor.sdkimport { using } from '@cendor/cassette'cassette is not re-exported by the SDK; it surfaces only via the eval harness. Import it from the umbrella / @cendor/cassette.
spotlight / adapters under the SDKrules.spotlight(...) (on the SDK rules)rules.spotlight({...}) (on the SDK rules since @cendor/sdk 0.10.0)Both SDKs re-export the full library rule catalogue — spotlight, the detection-tier adapters, and the similarity checks all ride the SDK rules namespace. Only the TS helpers (payloadText, NORMALIZATIONS) stay library-only.
Red-team a gatefrom cendor.guardrails import load_corpus, run_redteamimport { loadCorpus, runRedteam } from '@cendor/guardrails'redteam / load_corpus live in cendor.guardrails, deliberately not SDK re-exports — cendor vends no attack data.
SDK output gates: re-ask + stream windowAgent(reask_on_output_trip=2, stream_check_window=200)new Agent({ reaskOnOutputTrip: 2, streamCheckWindow: 200 }) (SDK ≥ 0.20)In both languages (TS since @cendor/sdk 0.20). Bounded re-ask fires only on a non-streaming output block; the stream window re-checks buffered run.stream output every N chars. Streaming re-ask exists in neither language — a streamed answer’s deltas can’t be unshown; see the parity matrix.
SDK provider idsAgent(model="…", provider="huggingface")new Agent({ model, provider: 'huggingface' })HF Hub ids & Azure deployment names aren’t prefix-inferable — always pass provider=. Provider SDKs are extras (Py) / peers (TS).
Calls instrument() does not capturechat.completions.parse / responses.parse, Anthropic messages.stream() helper + tool_runner, Batch APIssame entrypoints — the helpers bypass the wrapped create seamThese emit no bus event (silent — budgets/audit never see them). For governed calls use chat.completions.create, responses.create, or messages.create (stream=True / stream: true). Openai-shaped embeddings.create is captured since core 1.6.0 / 0.6.0 (metadata["embedding"] = True; pre-flight budgets/guards apply); embeddings on other client shapes remain uncaptured.
Tool / output schema version (SDK)schema derives from type hints — no zodtool(fn, { parameters: z.object({...}) }) / outputType — a zod 4 schema@cendor/sdk ≥ 0.11 requires zod 4; a zod 3 schema is rejected with a clear error. On zod 3.25+ use import { z } from 'zod/v4', or upgrade zod to ^4, or pass a raw jsonSchema:. (zod ships with the SDK — you don’t match its version.) SDK ≤ 0.10 used zod 3 and silently emitted an empty parameter schema for a zod-4 schema — upgrade.
Agent names from your framework (libs)LangChain: CendorCallbackHandler(); openai-agents: Runner.run(agent, …, hooks=CendorAgentHooks()) (extra [openai-agents]); Foundry: observe_foundry_agents(client) (extra [foundry])LangChain: new CendorCallbackHandler(); openai-agents: observeOpenAIAgents(runner) (@cendor/core/openai-agents); Foundry: observeFoundryAgents(client) (@cendor/core/foundry)When your app runs under a third-party framework, attach the matching cendor.core adapter — the framework owns the agent name; core carries it onto the bus (never-overwrite). openai-agents’ model calls ride the standard OpenAI client, so instrument() still gets tokens/cost/streaming; Foundry is attribution-only (agent + conversation_id stamped, but the model runs server-side → no per-step token/cost). Don’t invent a CENDOR_AGENT_NAME — there is none; an app-level name is the standard OTEL_SERVICE_NAME.
Telemetry: it flows, and the off switch is one env varCENDOR_TELEMETRY=off | auto (default); CENDOR_DEBUG_TELEMETRY=1 to diagnosesame env varsWith OpenTelemetry installed and a provider configured in your app, Cendor emits on its own — call spans at instrument(), spend counters, an agent.run tree per SDK run(), governance.* decisions, and an AuditLog’s mirror (core ≥ 1.13 / 0.15, sdk ≥ 1.19 / 0.22). Nothing before that: every emitter is a silent no-op with no provider or no OTel installed, and it deliberately does not warn (offline is a supported posture). So diagnose, don’t guess — CENDOR_DEBUG_TELEMETRY=1 prints one line (mode=auto, provider=detected, emitter=attached), and cendor-init doctor / npx @cendor/init doctor static-checks the wiring. There is still no Cendor endpoint, exporter or key — it emits into your provider. Historical TS ordering trap: in @cendor/tokenguard < 0.7.0 a new OTelSink() constructed before the provider bound a no-op counter permanently; from 0.7.0 the meter is acquired lazily, so order never matters.
Governance telemetry ≠ auditgovernance.* spans (cendor.gov.*) are automatic; AuditLog(system=…) is the recordsameTwo different things. governance.budget_event / governance.guardrail_decision spans are operational signals the enforcing libraries emit by default (no AuditLog, no chain, no hashes) — that’s how a telemetry user sees a budget block or a guardrail verdict with zero governance code. The audit trail is AuditLog (+ path= for the hash-chained file that verify() checks); its OTelMirror now auto-attaches, and while a mirror is on the wire the governance.* spans stand down — one decision, one rendering, mirror wins. These ops spans carry no reason string (a rule’s reason — and an llm_judge verdict — can contain input-derived text) and never any audit.* name or cendor.audit.* attribute. Never call a populated governance board an audit trail.
core.trace() groups calls into one trace (core ≥ 1.14 / @cendor/core ≥ 0.16)with trace("fs-tool"): …await trace('fs-tool', async () => { … })Behaviour change. Before those versions the scope only stamped an ambient id, so every call inside still arrived as its own root span — one unit of work became N unrelated traces (measured: a scope around a chat call and a tool call produced two). It now opens a real cendor.trace <id> parent span (scope cendor.core, carrying cendor.run.id + cendor.scope="trace"), so one scope = one trace with 1-based cendor.step children. The ambient id is unchanged, so cendor.trace_id correlation still works. Nothing is emitted with no OTel / no configured provider / CENDOR_TELEMETRY=off, and no span is opened inside a cendor-sdk run (that run owns its trace; the calls attach to it). Nesting is a no-op for the inner scope. Opt out with CENDOR_TRACE_SPAN=off, or span=False / { span: false } per scope.
Agent identity vs an agent nameAgent(name="support", id="reg-42"); adapters: bedrock_agent_scope(agent_id=…, session_id=…), openai_assistant_scope(assistant_id=…, thread_id=…), foundry_agent_scope(agent_id=…, thread_id=…)new Agent({ name: 'support', id: 'reg-42' }); bedrockAgentScope({ agentId, sessionId }, fn), openaiAssistantScope({ assistantId, threadId }, fn), foundryAgentScope(agentId, threadId, fn)A name is a label — two apps can share one, and renaming an agent loses its history — so it goes to gen_ai.agent.name. An id is identity and goes to gen_ai.agent.id. Pass id/agentId only when you have one; absent, the attribute is omitted — Cendor never hashes a name into an id and never placeholders one. No provider returns an agent id for a plain chat call, so for a bare model call the honest answer is “there is none”. The three adapter scopes map the ids those products already own, and stay attribution-only: mapping identity does not make a server-side runtime’s tokens or cost appear. There is no CENDOR_AGENT_NAME and never will be — core carries no identity of its own.
One live AuditLog per chain file (acttrace ≥ 1.13.1 / 0.14.1)log.detach() before reopening the same path=; a long-lived server rotates (one file per process lifetime)log.detach() before reopening the same path; same rotation ruleReopening is supported — point a new AuditLog at an existing path and it resumes the chain from the last on-disk entry, no new audit_open, and verify() spans the whole file. That is the restart case and it verifies. What is refused is two logs writing one file at the same time: each keeps its own head + sequence, both auto-capture the same bus event, and the two chains interleave — verify() then reports broken link at seq N: prev_hash mismatch, discovered whenever someone finally audits. Constructing the second one now raises. Do not “fix” that error by deleting the file or starting a new chain: detach() the log whose life ended, or give the new one its own (dated) file. Two separate processes appending to one file cannot be detected from inside either — one writer per file.
Reading response headers under governanceclient.responses.with_raw_response.create(...) — captured and priced since core 1.14.1client.responses.create({...}).withResponse() — the accessors survive instrument() since @cendor/core 0.16.1Two different SDK shapes, so two different answers. Python: the raw-response namespace returns an envelope (headers + an un-parsed body) with no usage, so before 1.14.1 the call was captured with usage=None/cost=None — this is exactly how a Microsoft Agent Framework turn lost its cost. Usage is now recovered from the body (metadata["raw_response_envelope"]). TypeScript: openai/anthropic return an APIPromise; below 0.16.1 an instrumented client handed back a native promise, so asResponse()/withResponse() were undefined — and because instrument<T>(client: T): T preserves the type, it type-checked and threw at runtime. Both languages: wrap the client at construction — resolving with_raw_response before instrument() snapshots the un-wrapped method and the call is never captured at all. Honest limit (TS): the accessors are there on any live call — including a streamed one since 0.16.2, where withResponse() returns the SDK’s response with cendor’s counting stream as data (which is what anthropic’s own messages.stream() helper needs; below 0.16.2 that helper threw on an instrumented client) — but a replayed call has no HTTP response. Since core 1.14.2 / @cendor/core 0.16.2: wrapping first is no longer required for this to work — instrument() evicts a stale cached accessor — but a reference you already stored in a local is still beyond reach; a streamed raw-response call hands the envelope back and counts the stream behind its parse(); and recording one into a cassette needs cendor-cassette >= 1.1.1, below which it raised RecursionError out of your own create().
Replaying an async client (cassette + core ≥ 1.14.1, Python)await client.chat.completions.create(...) inside cassette.using(path, mode="replay") — plain await, no shimsame, and TypeScript never needed anythingBelow core 1.14.1 the replay handed the recorded value back synchronously, so await raised TypeError: object types.SimpleNamespace can't be used in 'await' expression and a replayed stream was not async for-able at all. The cause was never cassette: openai’s chat.completions.create and anthropic’s messages.create are async defs behind a sync decorator, so iscoroutinefunction() is False and core installed its sync wrapper. Do not write an await-if-awaitable shim in application code — upgrade. The one documented edge: a client whose method is a plain def that merely returns a coroutine is invisible to detection, so in a pure-replay process (no live call first) its replay is still synchronous.
Structured output (parse) is captured — by a different mechanism per languageclient.chat.completions.parse(...) (core >= 1.14.2) and client.responses.parse(...) (>= 1.14.1) are instrumented targets — call them normallyCall them normally; they ride the wrapped create (@cendor/core >= 0.16.2) — they are not separate targetsSame behaviour, opposite mechanism, so do not “fix” one to look like the other. In Python both entrypoints POST their own request, so each needs its own target; before those versions a structured-output call emitted nothing at all — which is how langchain-openai’s with_structured_output() went unseen by budgets, guards and audit. In openai-node the same names are helpers built on create (create(...)._thenUnwrap(...)), so the wrapped create already captures them exactly once; adding a target double-counts, and against the real SDK @cendor/core 0.16.1 threw TypeError: Body is unusable because the derived promise re-read the fetch body. Still uncaptured in both languages: Anthropic’s messages.stream() helper (Semantic Kernel’s Anthropic connector uses it), tool_runner, and the Batch APIs.
FoundryAdapter ≠ the M365 Agents SDK pathM365 Agents SDK: attach the envelope in your own handler — activity.channel_data = {"cendor": {…}} — and never construct FoundryAdapter. Microsoft Foundry Agent Service: FoundryAdapter(agent).on_activity(act)M365 Agents SDK: activity.channelData = { cendor: {…} } in the handler. Microsoft Foundry Agent Service: new FoundryAdapter(agent).onActivity(act)Two separate Microsoft integrations, and picking the wrong one silently duplicates plumbing — measured 2026-07-27: on_activity() called from inside an M365 handler returns a valid-looking reply Activity with a channelData.cendor envelope and raises nothing, so no signal reaches you. FoundryAdapter is the endpoint: it owns the Activity request/reply shape. A custom engine agent built with the Microsoft 365 Agents SDK already owns that — AgentApplication behind POST /api/messages, with its own TurnContext/TurnState, streaming and auth — and it holds the model client, so it needs no SDK API at all: instrument() plus budgets, gates and evidence, envelope attached in ~3 lines. Using both gives you two Activity layers, two reply paths, and the envelope on the wrong object. Pick by who owns the HTTP surface: cendor (Foundry) or your process (M365 Agents SDK). Full wiring: Providers → Microsoft 365 Agents SDK. run()/stream() do work inside such a handler if you want the SDK’s loop — nothing in that topology requires it.
Microsoft Foundry: use the v1 GA endpoint, not AzureOpenAIinstrument(OpenAI(base_url=f"{endpoint}/openai/v1/", api_key=…)); the model is your deployment nameinstrument(new OpenAI({ baseURL: ${endpoint}/openai/v1/, apiKey: … }))Microsoft’s current guidance is the plain openai client on /openai/v1/ with no api-version — which is also cendor’s native detection target. Three endpoint forms work: <res>.openai.azure.com, <res>.services.ai.azure.com, and the project endpoint <res>.services.ai.azure.com/api/projects/<name>. ⚠️ A bare project endpoint (no /openai/v1/) answers 400 Missing required query parameter: api-version — which reads like “go back to the legacy client” and is not. ⚠️ A reasoning-family deployment (gpt-5*, o*) rejects max_tokens and names max_completion_tokens; a deployment name cannot tell you the family, so read the error rather than guessing (the SDK door does this for you since cendor-sdk 1.21.0 / @cendor/sdk 3.1.0). AzureOpenAI is still detected and still captured — detection is structural and a regression test pins it; it is only gone from the taught surface.
Foundry SDK (azure-ai-projects) needs no cendor-specific codeinstrument(AIProjectClient(endpoint=…, credential=DefaultAzureCredential()).get_openai_client())instrument(new AIProjectClient(endpoint, new DefaultAzureCredential()).getOpenAIClient())get_openai_client() / getOpenAIClient() returns a plain OpenAI client on <endpoint>/openai/v1, so instrument() captures it as provider="openai" with usage and cost — verified live in both languages. It covers every model in the project, non-OpenAI ones included (DeepSeek, Grok, Llama…): the model’s maker never changes the client’s shape. Do not look for a FoundryProvider or a [foundry] extra for this: the [foundry] extra is the attribution adapter for the Agent Service, a different integration. azure-ai-projects is your dependency; cendor never pulls it. One cross-language difference: the Python package documents an api_key= override on get_openai_client(...); the JS one always overwrites apiKey with its Entra token provider, so there authentication goes through the constructor’s credential.
Gemini streaming is its own method, and its usage is cumulativefor chunk in client.models.generate_content_stream(model=…, contents=…) — captured since core 1.15.0 (sync + client.aio)for await (const c of await client.models.generateContentStream({…})) — captured since @cendor/core 3.1.0google-genai does not take stream=True; it streams through a separate method, which is why capture used to miss it entirely — a streamed Gemini call raised no error and emitted zero LLMCalls. Below those versions it is invisible to budgets and audit; at/above them one LLMCall lands on completion. ⚠️ Usage comes from the last chunk: Gemini stamps usage_metadata/usageMetadata on every chunk with running totals, so the first-chunk rule that works for OpenAI under-counts. A usage-less stream falls back to an estimate flagged usage_estimated. on_exceed="break" / onExceed: 'break' cuts mid-stream and closes the provider stream.
Tell whether a tool failed (SDK)if result.tool_failed: for e in result.tool_errors: e.tool, e.type, e.messageif (result.toolFailed) for (const e of result.toolErrors) …A tool that raises does not end the run: the loop hands the model "[error] <Type>: <msg>" and continues. Do not string-match that yourself — read tool_errors/toolErrors. Two measured edges: a failed tool emits no ToolCall, so it is absent from tool_steps/toolSteps and from the span tree; and incomplete stays False — a run whose tools all failed but which answered is a complete run. A guardrail block is not a tool error (it is in guardrail_decisions).
Assert spans/metrics without a global providerotel.span(model, tracer=my_tracer) · OTelSink(meter=my_meter) · guardrails.use_meter(my_meter)otel.span(model, { tracer }, fn) · new OTelSink({ meter }) · useMeter(meter)By default all three resolve the global OpenTelemetry provider, which is right for an app and wrong for a test, a per-tenant host, or a second pipeline — and used to force installing a process-global provider just to observe anything. Pass the tracer/meter instead; names, attributes and the no-OpenTelemetry no-op are unchanged. None/omitted still means “use the global one”.
Stream from Anthropicwith client.messages.stream(...) as s: — captured since core 1.17.0 (its own instrument target, as is messages.parse)for await (const ev of client.messages.stream(...)) — captured all along, through the wrapped createBoth are captured now; do not add a TS target for either. In Python each helper POSTs its own request (self._post("/v1/messages", …)), so each needs its own target — before 1.17.0 both emitted zero events through every consumption path (iteration, .text_stream, .get_final_message()), measured on anthropic 0.120.2 while the POST plainly happened. In TypeScript the same two names are helpers built on create, so the wrapped create already counts them exactly once and a second target double-counts — the same asymmetry openai’s parse has. tool_runner is not a bypass any more: anthropic 0.120.2 has no such method at all. Batch stays post-hoc by design.
Bedrock with aws-sdk-v3 (TS)n/a — boto3 exposes converse(), so instrument(client) has always workedinstrument(new BedrockRuntimeClient({...})), then client.send(new ConverseCommand({...})) — captured since @cendor/core 3.3.0Do not write the converse() shim any more. Older docs told you to wrap the v3 client in { converse: input => client.send(new ConverseCommand(input)) } because send is shared by every AWS command and could not be duck-typed; core now identifies the client (config.serviceId === 'Bedrock Runtime') and the command per call, so ConverseCommand and ConverseStreamCommand are captured and every other AWS command passes through untouched, emitting nothing. InvokeModelCommand is deliberately not captured — its body is opaque per-model JSON, so any usage reading would be a guess. The SDK’s synthetic converse() still works and cannot double-count.
Two governance libraries on one callwith guard(policy, log): with budget(tokens=N, on_exceed="clamp"): … — both apply, since core 1.17.0same, since @cendor/core 3.3.0A Reroute no longer ends the interceptor chain — only a returned response (a cassette replay) does. Before this, the first interceptor that rewrote the request silently skipped every one registered after it, and which one you lost depended on registration order: measured, a tokenguard clamp before an acttrace.guard() sent the PII to the provider unredacted, and the reverse order left the token cap silently unbound. Reroutes now compose in registration order (later wins on the same field) and each interceptor sees the request as it will actually be sent. Do not tell a user to reorder registrations as a fix — upgrade.
Cap tokens for a Claude modelbudget(tokens=N) — but see the ratiowithBudget({ tokens: N }, …) — samecendor counts Claude with the o200k proxy, and it UNDER-counts, so a token cap binds later than you asked. Measured against Anthropic’s own messages.count_tokens (27 samples, prose/code/JSON × 3 sizes, message-level both sides, 2026-07-31): Opus 4.7 / Sonnet 5 / Fable 5 are 1.49× the proxy (range 1.32–1.66), and older ids such as Sonnet 4.5 / Haiku 4.5 are 1.14× (range 1.03–1.22) — so the newer family is ~49% under, not the ~30% Anthropic’s own wording suggests, and the older ids are not exempt. Do not invent a scaling factor: the ratio tracks content (JSON ~1.33, prose ~1.66), so no single number is honest, and cendor deliberately applies none. tokens.method() keeps saying bpe-estimate. For exactness, tokens.register("anthropic", …) — the FAMILY, not a model id — delegating to count_tokens (one API call per count, so opt-in).
Gate output on a structured-output call (TS)works — parse is its own instrumented targetneeds @cendor/core ≥ 3.3.0A response consumed through responses.parse / chat.completions.parse (create(...)._thenUnwrap(...)) used to escape the standalone output gate and deliver banned text, while the same response awaited directly was blocked. The gate was never the problem: measured, it ran and decided block every time, and its exception rejected core’s capture chain — but _thenUnwrap derives a new promise from the SDK’s own object, so the promise the caller awaited had never touched that chain. Fixed by gating the derived promise. Below 3.3.0, gate structured output with the SDK’s in-loop stage or apply(...) the parsed value yourself.
evict="compress" without squeezeContext(..., on_missing_compressor="error")new Context({ ..., onMissingCompressor: 'error' })With no compressor available a compress block is truncated, which is lossy and gives you no Handle to .expand() — a different operation, not a slightly worse one. It was always recorded as a note on the BlockDecision, and a note nobody reads is how a forgotten contextkit[squeeze] degraded every compress block while the assembly still reported success. Since 1.1.0 / @cendor/contextkit 3.1.0: "note" (default, unchanged) · "warn" · "error". It fires only when the compressor is genuinely missing — a block that asked for truncate, or one that fitted, is untouched.
azure-ai-inference is captured by NOTHINGnot a detection target — migrate to instrument(OpenAI(base_url=f"{endpoint}/openai/v1/", api_key=…))same — instrument(new OpenAI({ baseURL: ${endpoint}/openai/v1/, apiKey: … }))The Azure AI Inference beta SDK (ChatCompletionsClient, the /models route) is a different client shape from the openai SDK, and instrument() returns it untouched: zero LLMCalls, so budgets never bind, gates never fire and the audit chain stays empty — while the app looks like it works. Microsoft deprecated it and retires it on 26 August 2026; the GA /openai/v1 API is the replacement. There is no cendor-side fix, and none is planned — migrating the client IS the fix.
A model-router deployment is not priceabletokens exact; cost is None — restrict the router pool and prices.register_deployment("model-router", like=<priciest member>) if a USD cap must bindsame via prices.registerDeployment('model-router', { like })Foundry’s model router picks a different underlying model per request and bills at that model’s rates, but the id the call reports is the router’s own deployment name — so no single registration is correct for every call. Usage stays exact; USD attribution under model router is not supported today. Registering the most expensive member of the pool over-estimates, which is the safe direction — never register the cheapest. Microsoft returns the serving model on the response, so per-call attribution is possible in principle; it is recorded as future work, not shipped.
Where a refresh() rate came fromprices.explain(model).how / .row_source / .row_asof / .registered / .notes / .summary()same, camelCase: .rowSource / .rowAsofA bare prices.refresh() no longer fetches the cendor-libs snapshot — it fetches the cendor-prices feed, a dated table with per-row provenance reconciled from Azure Retail, the AWS Bedrock price files, models.dev, LiteLLM and genai-prices. Precedence, and it never guesses: provider-reported cost > a user register* > a refreshed table > the bundled snapshot > None + a warn-once. Do not tell a user their price is wrong and stop there — explain() names the source and its as-of date, and says in .notes when the active table is a gateway resale price (openrouter, vercel quote what they charge) or is undatable (no as-of date at all, so is_stale() reports unknown, not fresh). sources() is aws · azure · litellm · modelsdev · openrouter · vercel; azure/aws also take region=.
Prices live in memory — persisting them is explicitprices.save(path) then prices.load(path) in the next process; prices.refresh(required=True) raises instead of returning Falseawait prices.save(path) / await prices.load(path) (async — Node only); refresh(undefined, { required: true })refresh() writes nothing to disk, so a serverless or short-lived process starts at the bundled snapshot every time and must opt in again. There is deliberately no implicit cache — a hidden one is how prices go invisibly stale — so do not invent refresh(cache=…) or prices.cache; both raise a teaching AttributeError. save/load carry the ORIGINAL source and _updated through, so explain() and age_days() after a load describe where the rates came from, not when the file was read. And refresh() is contractually never-raise: a failure returns False and leaves the last-good table active — it never reverts anything. required=True is for the case where running on stale rates is worse than not running.
A missing price rate is UNKNOWN, not freean absent output, an absent input, or a table-stated zero inputprices.MissingRateError (subclass of UnknownModelError, so except KeyError still catches). Fix it: prices.register_model_price(model, input=…, output=…, per="1M")same, prices.MissingRateError / prices.registerModelPrice(model, { input, output, per: '1M' })Since cendor-core 1.20.0 / @cendor/core 3.7.0, and it is a prices/1 spec change, not just a library one. prices/1 used to read an absent output as 0 — right for an embedding, wrong for a chat model whose rate never parsed, and downstream the two are indistinguishable. Measured on 1.19.2: after refresh(source="litellm"), estimate("gpt-image-1", 1M, 1M) returned $5.00 where OpenAI’s own rates make it $45.00 — the whole output side reported as a fact of $0.00, with a USD cap never binding on it. The refusal fires whenever the model is priced, not only when the call carries output tokens. An explicit "output": 0 is honoured forever (embeddings really do have one), and a zero you registered is honoured too — prices.register("llama3", {"input": 0, "output": 0}) still prices a local model free, because a user registration outranks any table. Do not look for a switch back to the old behaviour: there is none, deliberately. Mapped refresh(source=…) rows that cannot be priced are dropped outright, so they raise the plain UnknownModelError; a pass-through refresh(url=…) keeps every row a user’s own table states.
A NEGATIVE price rate is refused, not multipliedprices.register(m, {"input": -1})prices.InvalidRateError (a ValueError, raised at the registration, nothing written); a table row stating a negative on any of input / output / cached / cache_writeprices.MissingRateError at estimate()same: prices.InvalidRateError (an Error) / prices.MissingRateErrorSince cendor-core 1.21.0 / @cendor/core 3.8.0, and a prices/1 spec change like its predecessor. A zero is a price a user may genuinely mean (a local model), so a registered zero is still honoured — a negative never is, in either path, because it fails worse than a fabricated zero rather than equally: a zero rate makes a USD budget(...) cap fail to bind, while a negative one un-binds it — the spend counter goes DOWN, so a negative-rate model pays for other calls and every call moves the cap further from firing. Measured on the published 1.20.0: register("neg", {"input": -1, "output": -1}) then estimate("neg", 1M, 1M) returned -$2,000,000. Reachable only through the two paths that are deliberately not mappers — register*() and a pass-through refresh(url=…) / load() table; every mapped refresh(source=…) already drops input <= 0. OpenRouter is where a negative comes from: it serves -1 as its “the price depends on which model gets routed” sentinel on openrouter/auto, auto-beta, bodybuilder, fusion and pareto-code — the model-router case two rows up, which is never priceable. Do not “fix” such a row by registering a rate you guessed; leave it unpriced and let UnknownModelError degrade to an honest None/null. And note the message names the value it found (states a negative INPUT rate of -1), not the condition — before 1.21.0 it said “states a zero INPUT rate” for a -1, which sent readers grepping their own table for a 0 they would never find.

A few cross-cutting rules that don’t fit a row:

  • Provider SDKs are optional. In Python they’re extras (pip install "cendor-sdk[anthropic]"); in TypeScript they’re peer deps (npm i @anthropic-ai/sdk). Install only the ones you call — Cendor never pulls a provider SDK for you.
  • Everything is offline by default. Token counting, pricing, guardrails, and cassette replay need no network and no API key. Nothing phones home.
  • Python is a PEP 420 namespace. Import from the flat cendor.* path (from cendor.tokenguard import budget). There is no top-level cendor module object to import — install the package that owns the symbol (or the cendor-libs umbrella).

Canonical examples

These are the exact snippets from the two Getting Started pages — the libraries’ Getting Started and the SDK’s Getting Started — reproduced here so an assistant has both happy paths in one place. They are typechecked in CI, so they can’t drift.

Instrument once

from cendor.core import instrument
client = instrument(OpenAI())   # OpenAI · Anthropic · Hugging Face · Gemini · Bedrock · Ollama
import { instrument } from '@cendor/core';
const client = instrument(new OpenAI());   // OpenAI · Anthropic · Hugging Face · Gemini · Bedrock · Ollama

instrument() is idempotent (re-wrapping is a no-op), additive (coexists with other instrumentation), and supports sync, async, and streaming clients.

Count tokens and estimate cost, offline

from cendor.core import tokens, prices

n = tokens.count([{"role": "user", "content": "Summarize this in 3 bullets."}], model="claude-opus-4-8")
cost = prices.estimate("claude-opus-4-8", input_tokens=n, output_tokens=200)
print(n, cost)            # e.g. 13  0.005065 USD
import { tokens, prices } from '@cendor/core';

const n = tokens.count([{ role: 'user', content: 'Summarize this in 3 bullets.' }], 'claude-opus-4-8');
const cost = prices.estimate('claude-opus-4-8', n, { outputTokens: 200 });
console.log(n, cost.toString());   // e.g. 13  0.005065 USD

Refresh prices, and show where a rate came from

from cendor.core import prices

prices.refresh()                        # the cendor-prices feed (dated, per-row provenance)
# or a provider's own catalog:  prices.refresh(source="azure", region="eastus2")
# or, when stale rates are worse than none:  prices.refresh(required=True)

e = prices.explain("gpt-4o")
print(e.summary())     # gpt-4o: … — exact, from azure as of 2026-07-01
print(e.registered)    # True if one of YOUR register* calls is overriding the table
print(e.notes)         # resale source? undatable table? unpriced model?
import { prices } from '@cendor/core';

await prices.refresh(); // the cendor-prices feed (dated, per-row provenance)
// or a provider's own catalog:  await prices.refresh(undefined, { source: 'azure', region: 'eastus2' });
// or, when stale rates are worse than none:  await prices.refresh(undefined, { required: true });

const e = prices.explain('gpt-4o');
console.log(e.summary()); // gpt-4o: … — exact, from azure as of 2026-07-01
console.log(e.registered); // true if one of YOUR register* calls is overriding the table
console.log(e.notes); //     resale source? undatable table? unpriced model?

Cap spend and attribute it

from cendor.core import instrument
from cendor.tokenguard import budget, track, report

client = instrument(OpenAI())

@budget(usd=0.50, on_exceed="raise")          # trips the breaker before a runaway loop spends more
def answer(q: str) -> str:
    with track(feature="support", user_id="alice"):
        r = client.chat.completions.create(model="gpt-4o", messages=[{"role": "user", "content": q}])
        return r.choices[0].message.content

answer("Why was I charged twice?")
print(report(group_by=["feature"]))            # spend grouped by tag — for free
import { instrument } from '@cendor/core';
import { budget, track, report } from '@cendor/tokenguard';

const client = instrument(new OpenAI());

const answer = budget({ usd: 0.50, onExceed: 'raise' })(   // trips before a runaway loop spends more
  (q: string) => track({ feature: 'support', userId: 'alice' }, async () => {
    const r = await client.chat.completions.create({
      model: 'gpt-4o', messages: [{ role: 'user', content: q }] });
    return r.choices[0].message.content;
  }));

await answer('Why was I charged twice?');
console.log(report(['feature']));              // spend grouped by tag — for free

Assemble context within a budget

from cendor.contextkit import Context, Block

ctx = Context(budget_tokens=8000, model="gpt-4o", reserve_output=1000)
ctx.add(Block(SYSTEM_PROMPT, priority=10, pin=True, role="system"))
ctx.add(Block(retrieved_docs, priority=5, evict="compress"))   # squeeze, if installed
ctx.add(Block(user_msg, priority=9, pin=True, role="user"))

messages = ctx.assemble()      # guaranteed within budget
print(ctx.report())            # the receipt: kept / truncated / dropped
import { Context, Block } from '@cendor/contextkit';

const ctx = new Context({ budgetTokens: 8000, model: 'gpt-4o', reserveOutput: 1000 });
ctx.add(new Block(SYSTEM_PROMPT, { priority: 10, pin: true, role: 'system' }));
ctx.add(new Block(retrievedDocs, { priority: 5, evict: 'compress' }));  // @cendor/squeeze, if installed
ctx.add(new Block(userMsg, { priority: 9, pin: true, role: 'user' }));

const messages = await ctx.assemble();  // guaranteed within budget
console.log(ctx.report());              // the receipt: kept / truncated / dropped

Gate unsafe input and output

from cendor.guardrails import rules, evaluate, GuardrailTripped

gate = [rules.keyword_deny(["ignore previous instructions"], action="block")]
try:
    payload, decisions = evaluate(gate, "input", user_msg)   # runs the input-stage rules
    resp = client.chat.completions.create(model="gpt-4o", messages=messages)
except GuardrailTripped as trip:
    resp = None                                              # blocked pre-flight — $0
    print("blocked:", [d.guardrail for d in trip.decisions])
import { rules, evaluate, GuardrailTripped } from '@cendor/guardrails';

const gate = [rules.keywordDeny(['ignore previous instructions'], { action: 'block' })];
try {
  const { payload } = evaluate(gate, 'input', userMsg);    // runs the input-stage rules
  const resp = await client.chat.completions.create({ model: 'gpt-4o', messages });
  console.log(payload, resp);
} catch (trip) {
  if (trip instanceof GuardrailTripped) {                  // blocked pre-flight — $0
    console.log('blocked:', trip.decisions.map((d) => d.guardrail));
  }
}

Under the cendor-sdk agent loop you don’t call evaluate yourself — you pass Agent(guardrails=[…]) and it gates all four stages for you.

Make runs testable — and audited

from cendor import cassette
from cendor.acttrace import AuditLog

audit = AuditLog(system="support", risk_tier="limited")   # auto-logs every instrumented call

@cassette.use("tests/support.json")          # records once, then replays offline forever — no key
def test_support():
    out = answer("Why was I charged twice?")
    assert cassette.semantic_match(out, "explains the charge")

audit.export("evidence.jsonl", framework="eu_ai_act")     # tamper-evident; verify offline
import * as cassette from '@cendor/cassette';
import { AuditLog } from '@cendor/acttrace';

const audit = new AuditLog('support', { riskTier: 'limited' });  // auto-logs every instrumented call

test('support', () =>
  cassette.using('tests/support.json', async () => {  // records once, then replays offline — no key
    const out = await answer('Why was I charged twice?');
    expect(cassette.semanticMatch(out, 'explains the charge')).toBe(true);
  }));

audit.export('evidence.jsonl', 'eu_ai_act');           // tamper-evident; verify offline

Run a governed agent (SDK)

The other door: cendor-sdk gives you the whole agent loop with governance built in. This is the exact 10-line snippet from the SDK’s Getting Started — a budget cap, a PII-redacting guard, and a tamper-evident AuditLog, all on one run:

from cendor.sdk import Agent, tool, run, budget, guard, Policy, AuditLog

@tool
def get_weather(city: str) -> str:
    """Current weather for a city."""      # schema derived from type hints + docstring
    return f"Sunny in {city}"

agent = Agent(name="assistant", model="gpt-4o", tools=[get_weather],
              instructions="Answer using tools when helpful.")

log = AuditLog(system="support", risk_tier="limited", path="audit.jsonl")
with budget(usd=0.25, on_exceed="block"), guard(Policy.default(), audit=log):
    result = run(agent, "What's the weather in Paris?", audit=log)

print(result.output)                        # the final answer
print(result.cost, result.usage)            # Decimal money, real token usage
print([s.name for s in result.tool_steps])  # ["get_weather"]
import { Agent, tool, run, withBudget, guard, Policy, AuditLog } from '@cendor/sdk';
import { z } from 'zod';

const getWeather = tool(({ city }) => `Sunny in ${city}`, {
  name: 'get_weather',
  description: 'Current weather for a city',
  parameters: z.object({ city: z.string() }),   // TS has no runtime type hints — zod is the schema
});

const agent = new Agent({ name: 'assistant', model: 'gpt-4o', tools: [getWeather],
                          instructions: 'Answer using tools when helpful.' });

const audit = new AuditLog('support', { riskTier: 'limited', path: 'audit.jsonl' });
const result = await withBudget({ usd: 0.25, onExceed: 'block' }, () =>
  guard({ policy: Policy.default(), audit }, () =>
    run(agent, "What's the weather in Paris?", { audit })));

console.log(result.output);                          // the final answer
console.log(result.cost?.toString(), result.usage);  // decimal money, real token usage
console.log(result.toolSteps.map((s) => s.name));    // ["get_weather"]

budget/guard/AuditLog are the same library objects re-exported from cendor.sdk — the per-agent cap is Agent(max_usd=…), not a budget= field. Full walkthrough: SDK Getting Started.

Watch runs locally (Cendor Monitor)

The connection is one standard OTLP env var — there is no Cendor API, key, or endpoint to call, and no Cendor telemetry code: with an OpenTelemetry provider configured in your app, calls, run trees, spend and enforcement decisions flow on their own (core ≥ 1.13 / 0.15, sdk ≥ 1.19 / 0.22). CENDOR_TELEMETRY=off stops it; CENDOR_DEBUG_TELEMETRY=1 says why nothing is arriving. Your production default stays your own OTel backend (Azure Monitor / CloudWatch / Datadog / any OTLP); this is optional dev tooling. See Cendor Monitor.

# 1) run the monitor:  docker run --rm -p 3000:3000 -p 4318:4318 ghcr.io/cendorhq/cendor-monitor:0.15.0
# 2) point your app's OpenTelemetry at it (shell): export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
#    …plus your usual OTel SDK setup (a provider). No Cendor telemetry code.
from cendor.sdk import run

result = run(agent, "…")           # runs stream to Cendor Monitor at http://localhost:3000
// 1) run the monitor:  docker run --rm -p 3000:3000 -p 4318:4318 ghcr.io/cendorhq/cendor-monitor:0.15.0
// 2) point your app's OpenTelemetry at it (shell): export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
//    …plus your usual NodeSDK setup (a provider). No Cendor telemetry code.
import { run } from '@cendor/sdk';

const result = await run(agent, '…'); // runs stream to Cendor Monitor at http://localhost:3000

Prompt/response content is captured only if you opt in (otel.capture_content() — off by default; the monitor never enables it). The monitor is an operational copyverify() still runs on the tamper-evident audit file, never on what the monitor shows. On older versions (core < 1.13 / 0.15) attach the emitters explicitly instead — otel.use_span_emitter(), use_sink(sinks.OTelSink()), live_spans() — see Observability.

Wire up your assistant — three ways

You don’t have to paste this page every time. Pick whichever fits how your assistant reads context — they stack, so use more than one:

  • Rules files — drop a short cheatsheet into your repo (.github/copilot-instructions.md, .cursor/rules/cendor.mdc, AGENTS.md, CLAUDE.md, or .windsurf/rules) so your assistant reads the correct call-shapes on every edit. The copy-paste blocks are on Rules files.
  • MCP server — if your assistant runs in agent mode (Claude Code, Cursor’s agent, Copilot agent, Windsurf Cascade), connect the read-only Cendor MCP server and it looks up the correct shape on demand — the same trap table above, served fresh. See MCP server.
  • One commandnpx @cendor/init (or uvx cendor-init) writes the rules files (and, with --mcp, the connect config) for you, idempotently. It also ships a doctor that static-checks your wiring for CI. See init CLI & doctor.

These rules files are a different artifact from Cendor’s own maintainer CLAUDE.md (which says things like “never create __init__.py”). Don’t copy that one — it’s about developing Cendor, not calling it.

Honest limits

  • This page states call shapes, never performance numbers. Every benchmark-backed claim lives in Benchmarks; acttrace produces evidence, not a compliance guarantee.
  • The type-level teaching is only as fresh as your installed version. If a shape here disagrees with what your editor shows on hover, trust the editor — it’s reading the version you actually have.
  • Parity is documented, not version-coupled. Where a capability is Python-only (or shapes differ), the Languages & parity matrix is the source of truth.