touchmαrkdocstouchmark.ai

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! });
OptionTypeDefaultWhat it does
api_keystring— (required)Secret, sent as Authorization: Bearer <api_key>. Also identifies your application (defined above).
base_urlstringhttps://api.touchmark.aiOverride for staging or a proxy.
strictbooleanfalseWhen true, an unreachable Touchmark throws instead of degrading. See degraded mode.
onDegrade(info: DegradeInfo) => unknownCalled 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_msnumber10000Total budget for a call including its internal retries (not per-attempt).
fetchtypeof fetchruntime globalOverride the HTTP implementation (non-browser runtimes, a test/proxy hook).
transportTransportReplace 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.

ArgumentTypeWhat it is
scope_idstringYour durable id for the scope (a project, document, conversation). The handle you persist.
user_idstringThe 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.

ArgumentTypeWhat it is
session_idstringThe session this event belongs to.
event_idstringUnique 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_idxnumberA 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_typestringWhat kind of event it is — an open string, validated against the event types registered for your application (see EventType).
payloadobjectJSON-serializable content describing the event — the input to scoring (see Payload).
base_price_usd?numberSet 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.

ArgumentTypeWhat it is
session_idstringThe session whose valuations to stream.
from_cursor?stringResume after an opaque cursor; omit to read from the session's start.
signal?AbortSignalStop 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_usd double-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):

  1. Lease/lock keyed by session_id. Before running the loop, acquire a lock on consumer:{session_id} (a Postgres advisory lock, a Redis SET NX PX lease 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.
  2. Route a scope's consumption to one worker. Hash scope_id to 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.

FieldTypeWhat it is
event_idstringThe priced event this valuation is for.
valuation_numbernumber1 for the first valuation, 2+ for a re-valuation of the same event.
eval_scoresRecord<string, number>Score in [0, 1] per eval — a full snapshot of every eval scoring the event, not a diff.
eval_explanationsRecord<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_usdnumberThe absolute fair price at this valuation, in USD. This is the value you apply — set the event's price to it.
delta_usdnumberThe 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_usdbilling.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's eval_scores, fair_price_usd, and delta_usd. Its URL token is the session_id, and the page is live from start onward, 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, and eval_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 its delta_usd instead.
  • 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.