Chapter 05
SDK reference
The HolonographClient is the surface an application — or a coding agent acting through one — uses end-to-end: construct it once, run the call flow (messages.create → handle → reportOutcome), and, when you want the lens to check the judge for you, attach a structured claim to a dimension so a verdict's trustworthiness becomes machine-readable. Two auxiliary namespaces (contract, availability) declare shape and gaps. This chapter documents every method.
Install
npm install @holonograph/client
# or
pnpm add @holonograph/client
# or
yarn add @holonograph/client
Single self-contained ES module, no runtime dependencies. Targets Node 18+ and any runtime with a global fetch. Fully typed; the package ships its own type declarations.
Construct a client
import { HolonographClient } from "@holonograph/client";
const client = new HolonographClient({
endpoint: "http://127.0.0.1:8080",
token: process.env.HOLONOGRAPH_TOKEN,
runMode: "production",
lensVersion: "lv_2026_07_01",
substrate: {
lensVersion: "lv_2026_07_01",
lightSourceIdentifier: "anthropic/claude/4",
runMode: "production",
provenance: "production",
operatorColumns: {}
}
});
Constructor options
| Option | Shape | Notes |
|---|---|---|
endpoint | string | URL where your running Holonograph lens is reachable. The README ships with http://127.0.0.1:8080 as its example host. |
token | string (opt) | Bearer token, when the lens requires one. |
runMode | enum (opt) | One of the canonical modes (production, test, eval, replay, local_dev). Sent as x-holonograph-run-mode on every request. Can be resolved per-call at request time; see Run modes. |
lensVersion | string | Identifier of the lens configuration this client should call under. Pinned into every evaluation event this client produces. |
substrate | object | The SubstrateSnapshot attached to every event. Carries lensVersion, lightSourceIdentifier, runMode, provenance, and operatorColumns; see below. |
emit | object (opt) | Delivery mode + buffering configuration for evaluation events. Four modes (sync, buffered, otlp, dual) trade caller latency against throughput and export path. Defaults to { mode: "sync" }. See Delivery modes below. |
The substrate object
| Field | Shape | Notes |
|---|---|---|
lensVersion | string | Which lens configuration was in effect. |
lightSourceIdentifier | string | Which model produced the output, as vendor/model/version. |
runMode | enum | Which run mode the evaluation ran under. |
provenance | string | Origin category of the run; see Substrate columns. |
operatorColumns | object | The operator-declared deployment-state columns, keyed by column id. For example: { prompt_hash: "v2-sha256-…", tool_schema_version: "3.1.0" }. |
Delivery modes: the emit option
The client defaults to synchronous delivery: every reportOutcome awaits the POST /lens/events round-trip and propagates errors to the call site. That is the safe default, and for many operators it is enough. When the evaluation call path becomes hot enough that caller latency starts to matter, or when events need to leave the process on an OpenTelemetry export path, three additional modes are available.
| Mode | What it does |
|---|---|
sync | Default. reportOutcome awaits the HTTP POST; errors throw at the call site. |
buffered | Bounded ring buffer, drop-on-overflow. reportOutcome resolves on enqueue; a background drain flushes to /lens/events. Fire-and-forget safe. |
otlp | Buffered, but the drain fans out as OpenTelemetry spans to an OTLP/HTTP traces endpoint (the sidecar or collector of your choice). |
dual | Buffered, and each drained batch fans out to both /lens/events and OTLP/HTTP, with per-channel delivery tracking. The spans on the OTLP side carry only the event index; the bulk payload lives in the HTTP body, and the collector reconciles by eventId. |
All three buffered modes share the same bounded buffer and the same completeness accounting. What differs is the drain channel.
Configuration
const client = new HolonographClient({
endpoint: "http://127.0.0.1:8080",
runMode: "production",
lensVersion: "lv_2026_07_01",
substrate: { /* ... */ },
emit: {
mode: "dual",
bufferSize: 2048,
flushIntervalMs: 1000,
sentinelIntervalMs: 30000,
windowMs: 10000,
otlp: {
endpoint: "http://127.0.0.1:4318/v1/traces",
headers: { authorization: "Bearer …" },
maxQueueSize: 4096
},
onWindow: (w) => metrics.record(w),
onFlushError: (err, event) => log.warn("flush failed", { err, event }),
onExtremeSustainedLoad: (e) => alerts.page("holonograph drop rate elevated", e)
}
});
| Field | Shape | Notes |
|---|---|---|
mode | enum | One of sync, buffered, otlp, dual. Default sync. |
bufferSize | number (opt) | Max events held in the ring buffer at once. Default 2048. Once full, the oldest events are dropped and counted. |
flushIntervalMs | number (opt) | How often the buffer is drained to the wire. Default 1000ms. |
sentinelIntervalMs | number (opt) | How often a session-liveness sentinel span is emitted on span-carrying channels. Default 30000ms. |
windowMs | number (opt) | Length of one completeness-accounting window. Default 10000ms. |
otlp | object (opt) | OTLP/HTTP traces endpoint configuration. Required for otlp and dual modes; see below. |
onWindow | function (opt) | Called once per completed accounting window with the window's CompletenessWindow snapshot. |
onFlushError | function (opt) | Called on per-event flush failures under buffered modes. Default console.warn. Under sync mode, flush errors throw at the call site instead. |
extremeSustainedLoad | object (opt) | { dropRateThreshold?: number, sustainedForMs?: number }. When drops exceed the rate for the sustained duration, onExtremeSustainedLoad fires. Default 1% for 60000ms. |
onExtremeSustainedLoad | function (opt) | Called when the extreme-load condition is met. Route to your alerting. |
emit.otlp
| Field | Shape | Notes |
|---|---|---|
endpoint | string | OTLP/HTTP traces URL. For example, http://127.0.0.1:4318/v1/traces. |
headers | object (opt) | Extra request headers on the OTLP export (auth tokens, tenant hints, whatever your collector expects). |
maxQueueSize | number (opt) | OTLP processor queue size. Defaults to 2 × bufferSize. |
Reading delivery completeness
A bounded buffer with drop-on-overflow only produces trustworthy analysis if the drops are counted. The client publishes an accounting window every emit.windowMs and exposes the current window snapshot as a read.
const window = client.completenessWindow();
if (window?.biasFlag) {
// drops occurred in this window; downstream analyses should treat
// the sample as potentially non-random over this interval
}
client.completenessWindow(): CompletenessWindow | null
Returns the current window's snapshot. Returns null under sync mode (no buffer, nothing to account for).
| Field | Shape | Notes |
|---|---|---|
lensVersion | string | Which lens version this window is accounted under. |
windowStartMs, windowEndMs | number | Window boundaries, in wall-clock ms. |
offered | number | Events received from the caller during the window. |
dropped | number | Events dropped at the buffer boundary (ingress-time bound). |
delivered | number | Events successfully egressed across all drain channels. |
failed | number | Events that reached a drain channel but the wire rejected. |
channels | object | Per-channel breakdown (http, otlp), each with { attempted, delivered, failed }. Distinguishes /lens/events loss from OTLP-export loss. |
completenessLowerBound | number | The ratio (offered − dropped) / offered. A window's floor coverage. |
biasFlag | boolean | true when drops occurred in the window. Signals the sample can no longer be treated as random over this interval. |
client.shutdown(): Promise<void>
Flushes any remaining buffered events and stops the background drain. Call before process exit under buffered modes so an in-flight window doesn't die with the process. No-op under sync mode.
process.on("SIGTERM", async () => {
await client.shutdown();
process.exit(0);
});
biasFlag. Fire-and-forget stays fire-and-forget.
The call flow
One evaluation is three calls: messages.create, then use .result, then .reportOutcome(...).
const handle = await client.messages.create({
surfaceId: "classify-intent",
messages: [{ role: "user", content: "why was I charged twice?" }],
tools: [ /* ... */ ]
});
const answer = handle.result; // read/act on the model's response
const isCorrect = grade(answer); // your evaluation logic
await handle.reportOutcome({
dimensions: [{
dimensionId: "correctness",
passed: isCorrect,
expected: "a correct answer to the user's question",
actual: summarize(answer)
}],
cohortTags: ["customer-tier=enterprise"]
});
messages.create() mediates the model call and returns a handle. handle.result is the response you use in your agent. handle.reportOutcome() closes the loop; it persists the evaluation event that binds this call into your history.
messages.create(req)
Every field on the request is optional except surfaceId and messages. The rest are passed through to the model when relevant, or interpreted by the lens.
| Field | Shape | Notes |
|---|---|---|
surfaceId | string (req) | The surface this call belongs to. Must exist on the active contract. |
messages[] | array (req) | The conversation turns. |
system | string (opt) | System prompt. |
systemOverride | string (opt) | Overrides the system prompt for this call only. |
additionalSkills | string[] (opt) | Extra skills to inject for this call. |
additionalContextTags | string[] (opt) | Extra context tags this call should carry. |
lightSourceIdOverride | string (opt) | Overrides the surface's default routing to a specific vendor. |
maxOutputTokens | number (opt) | Cap on the model's output length. |
temperature | number (opt) | Model sampling temperature. |
tools | array (opt) | Tool declarations for the model to invoke. |
toolChoice | object (opt) | Which tool the model must invoke, if any. |
cacheControl | object (opt) | Caching hints for the model provider. |
correlationId | string (opt) | Pass one in to bind this call to a prior call's correlation. Otherwise generated. |
The call handle
The object returned by messages.create().
| Member | Shape | Notes |
|---|---|---|
.result | object | The primary model's response. Use this as the answer in your agent. |
.observerRecords[] | array | Under multiplex routing, the observer vendors' captured records. Empty when not multiplexing. |
.reportOutcome(outcome) | method | Persist the evaluation event that closes this call. |
.gradeObserver(recordOrId, outcome) | method | Attach grades to a cross-vendor observer call before reporting, when the lens returned observer records. Call this before reportOutcome. |
reportOutcome(outcome)
| Field | Shape | Notes |
|---|---|---|
dimensions[] | array (req) | One entry per dimension declared on the surface. Each entry: { dimensionId, passed, expected, actual }. |
cohortTags[] | string[] (opt) | Discrete labels to slice on later. For diagnostic substrate columns, use the <column.id>=<value> form. |
sideEffects[] | array (opt) | Any downstream actions this call triggered; useful for correlated fixtures and lessons later. |
fixtureId | string (opt) | The fixture this call satisfied, when the call was run as part of a conformance check. |
Dimension entry shape
| Field | Shape | Notes |
|---|---|---|
dimensionId | string | Must match a dimension declared on the surface's contract. |
passed | boolean | Whether the dimension passed the evaluation. |
expected | string | What good looked like: the expected shape of the response. |
actual | string | What the model actually produced (or a short summary). |
claim | object (opt) | What the judge asserted, as fields the lens can verify — for the verdict-reliability layer to check (see below). A JudgeClaimReport; omit it and the dimension is simply not claim-instrumented. |
Calling reportOutcome persists the evaluation event to Holonograph's substrate: one row, four layers bound (see The four-layer snapshot). What Holonograph does with that event afterwards is the subject of the analysis chapters.
Verdict reliability — let the lens check your judge
A score is only as trustworthy as the judge that produced it, and an LLM judge can misread its own input: accuse the model of inventing a value that was sitting in a tool result, or of skipping a tool it actually called. The lens catches that deterministically — but only if the judge hands it a structured claim to check, instead of burying the accusation in prose. Attach a claim to any judged dimension:
await handle.reportOutcome({
dimensions: [
{
dimensionId: "grounded",
passed: false,
expected: "only facts present in the tool results",
actual: "claimed a refund was issued and cited a total of $84.20",
// What the judge ASSERTED, as fields the lens can verify — not prose.
claim: {
status: "emitted",
assertions: [
// "the model cited a value that is not in what it read"
{ kind: "datum-absent", datum: "$84.20", reference: "the lookup total was $48.20" },
// "the model claimed an action without calling the tool that performs it"
{ kind: "tool-not-invoked", tool: "issue_refund" }
]
}
}
]
});
The lens checks each assertion against the captured call — is the accused value actually absent from everything the model read? was that tool really never invoked, anywhere across the whole conversation? — and reports, per assertion, whether the record corroborates or contradicts the judge. A contradiction is the loud case: the judge's verdict rested on something it misread, so that score cannot be trusted. Two assertion kinds ship today:
| Assertion kind | Shape | What the lens verifies |
|---|---|---|
datum-absent | { datum, reference? } | A value the judge says was fabricated. It splits the accused datum from the optional reference the judge measured against, so the lens verifies the accusation, never the yardstick. |
tool-not-invoked | { tool } | A tool the judge says was never called. The check reads the whole captured conversation, so a call made in an earlier turn still counts. |
A dimension with no claim is simply not claim-instrumented — the lens stays honest-null about it rather than guessing, so you opt in one dimension at a time. If the judge is meant to emit a claim but its output is malformed, report { status: "malformed", reflectionAttempted: <boolean> } — the lens raises that as a judge-malfunction signal rather than silently trusting a broken instrument. The JudgeClaimReport, JudgeAssertion, and JudgeAssertionKind types are exported so you can build and validate the claim before you send it.
Reading it back — the pre-action check
This is the read to run before you act on a verdict — before a coding agent decides a fix, escalates, or trusts a green board. Rather than take one score at face value, read the reliability layer over a window and let the record decide whether the verdict is safe to act on. There is no dedicated client method yet; use client.callDirectly or a plain fetch against your lens endpoint.
GET /holonograph/verdict-reliability?surfaceId=<id>&since=<iso>&until=<iso>
| Field | Shape | Notes |
|---|---|---|
eventCount | number | Evaluation events in the window. |
tierTally | object | How many verdicts fell into each reliability tier. |
flagged[] | array | The verdicts the layer flagged as not safe to act on at face value — a judge the record contradicted is the loud case. |
trustDrivers | object | What most moved reliability over the window. |
corpusRates | object | Per-predicate firing rates from the reliability corpus (see below). |
Has the scale stopped discriminating? — the saturation read
One entry in that corpus is worth an agent's attention before it trusts a numeric score: saturating-anchor-regime. It reports a candidate, at window scope, when a reading has come to rest at an anchor, so distinct inputs stop being told apart. It is a candidate, not a settled verdict: the read names the measurement that would confirm or discharge it. An agent reading a graded score should check this first: when the candidate is present, rank by the reliability tier, not the score magnitude. The reliability read aggregates these per entry over the window:
{
entryId: "saturating-anchor-regime",
evaluated: 128,
flagged: 41,
assessed: 128,
notApplicable: 0,
firingRateAmongApplicable: 0.32
}
Which judge produced this? — per-dimension identity
When several focused judges evaluate one response — one per dimension — each dimension can carry its own judge identity on DimensionResult.judge, so the lens groups and attributes each on its own. Where two facets move together, it reports the co-transition rather than guessing which to credit. Read how much of a surface each judge covers at GET /lens/facet-coverage. For an agent, this answers a question it should ask before trusting a verdict: whose verdict is this, and is that judge even watching the part I am about to change?
Multiplex: grade the observers
When the surface's contract routes to more than one vendor, the primary's response comes back as handle.result and the observers' captured records come back as handle.observerRecords[]. Grade the observers before reportOutcome so all vendors' outcomes land in the same evaluation.
for (const rec of handle.observerRecords) {
await handle.gradeObserver(rec, {
dimensions: [{
dimensionId: "correctness",
passed: isCorrect(rec.result),
expected: "a correct answer to the user's question",
actual: summarize(rec.result)
}]
});
}
await handle.reportOutcome({
dimensions: [{
dimensionId: "correctness",
passed: isCorrect(handle.result),
expected: "a correct answer to the user's question",
actual: summarize(handle.result)
}]
});
See The multiplexer for how routings are declared.
Contract
contract.register(contract)
Registers or replaces the surface contract for this agent. Returns a ContractSummary reflecting the newly-active shape. See Surface contracts for the full contract shape and the summary fields.
const summary = await client.contract.register({
agentId: "billing-triage",
surfaces: [ /* ... */ ]
});
console.log(summary.surfaces[0].dimensionCount);
Availability
availability.mark(req, opts?)
Records that a surface is (or is not) available for evaluation over a given interval. Two modes:
- Single: one transition event: "this surface just became unavailable" or "…just came back." Pass
mode: "single"with atransition. - Paired: bound an interval you already know the extent of. Pass
mode: "paired"withunreachableFromandunreachableUntil. The response includes apairingreference so downstream analyses can bracket the interval cleanly.
await client.availability.mark({
mode: "single",
surfaceId: "classify-intent",
transition: "unavailable",
reason: "vendor-outage"
});
Response:
{ "eventIds": ["evt_..."], "pairing": null }
Headers & run mode resolution
The client sends two headers on every request:
Authorization: Bearer <token>: when the client was constructed with atoken.x-holonograph-run-mode: <mode>: from the client'srunMode, unless resolved per-call. A missing mode is rejected by the daemon by design (fail-closed).
For scenarios where the run mode is determined at request time (Express middleware, async work, custom resolution), see Run modes.
Escape hatch: callDirectly
For scenarios that need the full request shape (a bespoke bridge, a replay tool, or an integration test that wants to prod an edge of the wire), the client exposes a lower-level callDirectly method that skips the messages.create ergonomics and forwards the request as-authored.
const result = await client.callDirectly(rawRequest);
Prefer messages.create for anything user-facing. callDirectly is an escape hatch, not the everyday path.
Errors
Errors from the lens surface as HolonographHttpError, which carries the HTTP status, an error code, and any details the lens returned. There are additional typed error classes for the grading and availability flows so a catch block can discriminate on class.
import { HolonographClient, HolonographHttpError } from "@holonograph/client";
try {
await handle.reportOutcome(outcome);
} catch (err) {
if (err instanceof HolonographHttpError) {
console.error(err.status, err.code, err.details);
} else {
throw err;
}
}
The underlying wire shape every error carries:
{
"error": "human message",
"code": "error_code",
"details": { }
}
4xx covers validation, auth, and not-found. 5xx is internal. 501 means the feature isn't wired on the given deployment.
Advanced: HttpTransport
The client is built on an HttpTransport primitive that's exported for advanced use: swapping in a custom fetch, adding request interceptors, or running the transport under a different runtime harness. Most integrations never touch it. If yours does, the transport surface is fully typed; the tests in the package are the working reference.