API reference
Every public symbol of @touchmark/sdk. No protobuf or gRPC knowledge assumed. For the
philosophy behind the shape, see the mental model; for failure
handling, see Errors & recovery.
import { Touchmark } from "@touchmark/sdk";
new Touchmark(options)
Construct one client per backend service and reuse it. The api_key binds for the client's
lifetime and scopes it to your application — the product you're integrating (a raw model API, an
agent platform like Lovable, Ramp's internal tooling). It's the unit your event-type schemas and
evals are registered against — we configure those with you when you onboard — and your api_key
identifies it, so there's no separate id to pass.
const tm = new Touchmark({ api_key: process.env.TOUCHMARK_API_KEY! });
| Option | Type | Default | What it does |
|---|---|---|---|
api_key | string | — (required) | Secret, sent as Authorization: Bearer <api_key>. Also identifies your application (defined above). |
base_url | string | https://api.touchmark.ai | Override for staging or a proxy. |
strict | boolean | false | When true, an unreachable Touchmark throws instead of degrading. See degraded mode. |
onDegrade | (info: DegradeInfo) => unknown | — | Called once per degraded call — each emit/end that degrades, not once per outage — so a degrade is never silent. Wire it to your metrics/logging. Anything it throws or rejects with is swallowed. |
timeout_ms | number | 10000 | Total budget for a call including its internal retries (not per-attempt). |
fetch | typeof fetch | runtime global | Override the HTTP implementation (non-browser runtimes, a test/proxy hook). |
transport | Transport | — | Replace the whole transport (advanced/testing). When set, base_url / timeout_ms / fetch are ignored. |
tm.newEventId(): string
Returns a fresh, random id (a UUIDv4) for when an event has no natural domain id of its own.
It is deliberately not time-sortable: an event_id is only an idempotency key and a label on
returned valuations — the event log is ordered by event_idx, never by event_id — so a random,
opaque id is all that's needed. If you already have a unique id for the event, use that instead.
tm.session
All four session methods take a single named-arguments object.
start({ scope_id, user_id }): Promise<{ session_id }>
Open — or idempotently re-fetch — the session for a scope.
| Argument | Type | What it is |
|---|---|---|
scope_id | string | Your durable id for the scope (a project, document, conversation). The handle you persist. |
user_id | string | The end user the scope belongs to. |
Returns the server-minted session_id. Idempotent on scope_id: calling it again returns the
existing open session, or mints a fresh one if the previous lapsed — so you never have to persist
session_id itself, and crash recovery is just "call start again." start always surfaces errors
(it never degrades — there is no safe session_id to invent).
emit({ session_id, event_id, event_idx, event_type, payload, base_price_usd? }): Promise<void>
Emit one event. Fire-and-forget — it returns nothing and carries no valuation. Call it without awaiting it on your user's critical path.
| Argument | Type | What it is |
|---|---|---|
session_id | string | The session this event belongs to. |
event_id | string | Unique within the session, and your idempotency key — reuse it verbatim on a retry so the server de-duplicates. The flip side: two distinct events must have distinct ids — reuse one for a different event within a session and the second is taken as a retry and silently dropped (a lost billable event). newEventId() (a fresh UUID) is always safe; a natural id works only if it's unique per session. |
event_idx | number | A per-session counter, contiguous from 0 (0, 1, 2, …), stamped at the event's birth. The engine orders the log by this, not by arrival, so out-of-order or late emits still slot in correctly. It is per session: when you open a new session (e.g. after session_closed recovery), reset it to 0 — the first event of the new session is event_idx 0. Must be a non-negative integer. |
event_type | string | What kind of event it is — an open string, validated against the event types registered for your application (see EventType). |
payload | object | JSON-serializable content describing the event — the input to scoring (see Payload). |
base_price_usd? | number | Set it to price the event (your current price for it, in USD); omit it for a context event that is scored but settles no money. A finite USD amount. |
emit validates payload, event_idx, and base_price_usd before sending; a bad value throws
TouchmarkError("invalid_payload") immediately (in both strict and non-strict mode — a caller bug
always fails loud). When Touchmark is unreachable it degrades by default.
end({ session_id }): Promise<void>
Close the session. Returns nothing and does not flush — there is no pending-valuation queue to
drain; valuations already produced keep arriving on the stream. (You don't have to call end;
sessions also lapse on their own after about a week with no emits — consuming the valuation stream doesn't reset the clock.) Degrades on unreachable, like
emit.
Why close it (optional, but worth it at a natural close — project archived, document submitted):
an open session is a live resource — your stream long-polls it per session — so closing it rather
than waiting out the ~1-week lapse sheds that polling load. Its session_id is also the public,
no-account session-page URL, so you may not want a live session and its exposed id lingering. And it
frees the scope_id so the next start begins a fresh session immediately. Late re-valuations — a
later valuation revising an earlier event's price — still arrive on the stream, so keep consuming it
after end.
streamValuations({ session_id, from_cursor?, signal? }): AsyncIterable<Valuation>
The sole channel through which valuations reach you. A cursor-resumable pull you run in its own
loop, one per session, independent of emit.
| Argument | Type | What it is |
|---|---|---|
session_id | string | The session whose valuations to stream. |
from_cursor? | string | Resume after an opaque cursor; omit to read from the session's start. |
signal? | AbortSignal | Stop the stream when it aborts. |
const ac = new AbortController();
for await (const v of tm.session.streamValuations({ session_id, signal: ac.signal })) {
billing.setEventPrice(v.event_id, v.fair_price_usd); // absolute price — idempotent, gap-proof
}
It is resilient: a dropped connection or per-attempt timeout reconnects from the held cursor, so
a transient outage costs a delay, not a lost valuation (at-least-once delivery — harmless, since
applying the absolute fair_price_usd is idempotent). Fatal errors (e.g. auth) surface and end the
stream.
Delivery is at-least-once, and the cursor lives in memory for the life of this call — it
advances as you iterate, and a transient reconnect resumes from where it's held (no loss). Because
the in-process stream keeps moving forward, dead-letter any apply that fails (catch the throw,
park the valuation, keep going) — a failed apply isn't retried for you within the same run. A process
restart is covered separately: a fresh streamValuations call with no from_cursor re-reads from
the session's start, so anything missed is re-delivered and your idempotent absolute apply just
re-writes the same fair_price_usd — no money lost. (Persist the latest from_cursor if you'd rather
resume mid-session instead of from the start.) Make your billing write idempotent on event_id and
apply it promptly.
v1 realizes the stream as a repeated long-poll under the hood. Server-push is a later, additive upgrade behind this exact same method — your code won't change.
Run exactly one consumer per session
A session's stream is a broadcast: every consumer of the same session_id gets the full
stream — there's no server-side fencing (the cursor is per-request, not per-consumer). In a
single-process app, the per-session loop already gives you exactly one. In a multi-instance
backend (≥2 instances, rolling deploys, HA failover) you must single-flight the consumer across
instances yourself, or two instances each run the loop for the same session.
What a second consumer does and doesn't break:
- Price stays correct. You apply the absolute
fair_price_usd, so a duplicate apply is a no-op (idempotent) — the bill is right even with two consumers. delta_usddouble-counts in your UI. It's the per-step +surcharge/−refund; showing it twice tells your user the wrong change. You don't bill from it, so money is unaffected — only the displayed delta.- Side effects in your apply fire twice — silently. If your apply does anything beyond setting the price — an audit-log row, a webhook to your end user, a "your charge changed" email — a second consumer fires each one again. This is the dangerous case: no error, no log, just a duplicate.
The pattern (pick one):
- Lease/lock keyed by
session_id. Before running the loop, acquire a lock onconsumer:{session_id}(a Postgres advisory lock, a RedisSET NX PXlease you renew, your job-runner's "one active job per key"). Hold it for the life of the loop; on crash the lease expires and another instance takes over. One holder, one consumer. - Route a scope's consumption to one worker. Hash
scope_idto a shard and run that session's loop only on the owning worker (consistent hashing, a partitioned queue, a sharded deployment). No lock on the hot path; the cost is rebalancing when membership changes.
Make non-price side effects idempotent anyway — defense in depth. It also covers the
at-least-once redelivery a single consumer already sees on reconnect (above), and an in-flight
handoff where two leases briefly overlap. Use a natural idempotency key per side effect:
(event_id, valuation_number) for "this exact valuation," or (event_id, fair_price_usd) for "this
price." Insert the audit row under a UNIQUE constraint on that key (ON CONFLICT DO NOTHING); send
the webhook/email with that key as the provider's idempotency key, or gate it on a first-write-wins
insert. Only the absolute price write is safe to repeat blindly — wrap every other effect.
Types
Valuation
Touchmark's verdict for one priced event.
Touchmark may issue more than one valuation for the same event over time — a later one is a
re-valuation (valuation_number 2+). Each carries the absolute fair_price_usd, so you always
apply the newest.
| Field | Type | What it is |
|---|---|---|
event_id | string | The priced event this valuation is for. |
valuation_number | number | 1 for the first valuation, 2+ for a re-valuation of the same event. |
eval_scores | Record<string, number> | Score in [0, 1] per eval — a full snapshot of every eval scoring the event, not a diff. |
eval_explanations | Record<string, string> | The judge's plain-language reason per eval, keyed by eval_id. Sparse: only LLM-judge evals carry prose; deterministic ones are score-only. Carries no verbatim user content, so it's safe to show the end user — see Explaining a price to your customer. |
fair_price_usd | number | The absolute fair price at this valuation, in USD. This is the value you apply — set the event's price to it. |
delta_usd | number | The change since the previous valuation, in USD (fair now minus fair before; the first is measured against base_price_usd). Informational — the +surcharge / −refund to show your user; you don't bill from it. |
Applying valuations — money model. Amounts are plain USD numbers. Apply each valuation by setting
the event's price to its absolute fair_price_usd — billing.setEventPrice(v.event_id, v.fair_price_usd). Because you write an absolute, applying the same valuation twice is a no-op
(idempotent) and a valuation that never arrives is superseded by the next valuation of that same
event (gap-proof, per event_id) — so you
write no dedup and no gap-reconciliation. The base_price_usd you supplied is the initial
valuation: a priced event has a price the moment you emit it, and each valuation supersedes it with
a fresh absolute. If no valuation ever arrives for an event, the base price simply stands — so there
is never an unpriced event, and never a gap to reconcile. delta_usd is the change since the previous
valuation (fair_(i+1) − fair_i, with fair_0 := base_price_usd); it's there so you can show your
user the +surcharge / −refund without recomputing it — not a number you bill from.
Precision. Every amount is an exact image of an integer count of micro-USD ($0.000001 — the
smallest unit Touchmark settles in; prices you send are rounded to 6 decimals, prices you receive are
exact). Float error appears only in your invoice-total summation (the 0.1 + 0.2 problem) — sum in
integer micro-USD (or a decimal money library) and round once at display. USD only; multi-currency is
undefined in v1.
Payload
type Payload = Record<string, unknown>;
Descriptive event content — the input to scoring. Must be JSON-serializable: plain objects and
arrays, finite numbers, strings, booleans, and null. The SDK rejects anything that wouldn't
survive a JSON round-trip (undefined, bigint, functions, NaN/Infinity, Date, class
instances, cycles) up front as invalid_payload, with a path to the offending value — so it can't be
silently mangled on the wire. The server additionally validates the payload against the schema
registered for your application's event_type. Note: base_price_usd is not part of payload —
it's a sibling argument on emit.
What schema? There is no public catalog of payload shapes — each event type's schema is the one
we configure with you when you onboard, scoped to your application. A simple integration starts
tiny: a model_output event is typically just { text: "…what your model produced…" }. But your
event types and their exact required fields are whatever we agree at onboarding — a single
event_type can carry a different shape for a different application (a multimodal or structured
output, say), so { text } is a typical minimal shape, not a fixed contract. If you're unsure what a
given event_type expects, ask your Touchmark contact — during the private beta that's a human,
usually a same-day answer. We give you each shape, and add new ones, without an SDK upgrade.
EventType
type EventType =
| "user_input" | "tool_used" | "user_interrupted"
| "diffs" | "model_output" | "fields_corrected_pct"
| (string & {}); // any custom type your application defines
The well-known values get editor autocomplete; the (string & {}) arm keeps the field open so
your application can define its own event types, validated at the edge against the types you
registered. A trivial application (a raw model API) emits only "model_output".
TouchmarkError
class TouchmarkError extends Error {
readonly code: TouchmarkErrorCode;
readonly cause?: unknown; // the underlying error the SDK translated
}
type TouchmarkErrorCode =
| "session_closed" | "session_not_found" | "auth"
| "invalid_payload" | "timeout" | "unreachable" | "rate_limited";
Every failure the SDK surfaces is a TouchmarkError with one of these seven codes. Branch on .code
(never on the message). What each one means and exactly how to handle it is in
Errors & recovery.
DegradeInfo
Passed to your onDegrade hook at each degraded-mode fallback.
interface DegradeInfo {
method: "emit" | "end"; // which call degraded
code: TouchmarkErrorCode; // always "unreachable" today
session_id: string;
event_id?: string; // present for emit; absent for end
}
Degraded mode
When Touchmark is unreachable, the SDK is fail-safe by default: emit and end swallow the
outage, return normally, and fire onDegrade — so your hot path keeps running. A degraded emit is
not delivered and the SDK does not buffer or replay it, so no valuation follows for it: the
base_price_usd you supplied is the price that stands, unless you re-emit the event (same
event_id) — e.g. enqueue it from onDegrade into your own outbox and retry on reconnect. Emits
that did land are unaffected. start is the exception: it always throws (a fabricated session_id
would just make the next emit fail). Every non-unreachable error always throws, even in
non-strict mode.
Set strict: true to opt out of the safety net entirely — then an unreachable Touchmark throws
everywhere. streamValuations is separate from degraded mode: it reconnects from its cursor on a
transient drop and surfaces only fatal errors.
See Errors & recovery → unreachable for the
full table and a worked onDegrade.
Explaining a price to your customer
A valuation moves your end-user's bill, so sooner or later you'll field "why was I charged this?" There are two built-in ways to answer it:
- Link Touchmark's session page. Every session has a public, no-account, shareable page whose
sole purpose is to explain, per event, what was scored and how the price moved —
base_price_usd, then each valuation'seval_scores,fair_price_usd, anddelta_usd. Its URL token is thesession_id, and the page is live fromstartonward, so you can drop the link straight into your surcharge/refund notice or invoice line item. (We share the exact URL format when you onboard.) The page shows the last 24 hours of session activity; older events stay in Touchmark's retained log but are off-window on the public page — so for a dispute about an older charge, use the render-it-yourself path below from your persisted stream. - Render it yourself. Everything that page shows also reaches you on the valuation stream —
eval_scores,fair_price_usd,delta_usd, andeval_explanations— so you can persist and display your own breakdown with no dependency on the hosted page. Persisting the stream is also what lets you explain a charge older than the session page's 24-hour window.
eval_explanations is the human-readable half: the judge's reason per eval — e.g. "code quality
scored 0.82 (above average); pricing adjusted by +$0.04." Two things to know before you surface it:
- It's sparse. Only LLM-judge evals write prose; a deterministic eval is score-only. Where prose
is absent, show that eval's
[0, 1]score and itsdelta_usdinstead. - It's customer-safe by construction. It carries no verbatim user content — it's authored for that public page — so you can forward it to the end user as-is.