npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@centient/sdk

v2.7.0

Published

TypeScript SDK for Centient — AI agent memory and context engineering infrastructure

Readme

@centient/sdk

TypeScript SDK for Centient -- AI agent memory and context engineering infrastructure.

Installation

npm install @centient/sdk

Or with pnpm:

pnpm add @centient/sdk

Quick Start

import { EngramClient, createEngramClient } from "@centient/sdk";

// Create client from environment variables
const client = createEngramClient();

// Or with explicit config
const client = new EngramClient({
  baseUrl: "http://localhost:3100",
  apiKey: "your-api-key",
});

No-auth daemons: against a local engram daemon with auth disabled, omit apiKey entirely. The SDK only sends X-API-Key when apiKey is truthy — a no-auth daemon accepts key-less requests but rejects a provided placeholder/bogus key with 401.

// Create a session
const session = await client.createSession({
  sessionId: "2026-01-17-feature-work",
  projectPath: "/path/to/project",
  embeddingPreset: "balanced",
});

// Save notes
await client.createNote(session.id, {
  type: "decision",
  content: "Using PostgreSQL with RLS for multi-tenant data isolation",
});

// Search session memory
const results = await client.search(session.id, {
  query: "database security",
  limit: 5,
});

Request logging

The client is silent by default — no console output, ever. To make retries, exhausted retry budgets, and timeouts observable (e.g. when diagnosing a hang vs. a retry storm), inject an optional logger:

import { createEngramClient } from "@centient/sdk";
import { createLogger } from "@centient/logger";

const client = createEngramClient({
  logger: createLogger({ service: "my-agent" }),
});

logger is a minimal structural interface (ClientLogger): any object with context-first debug(context, message) and warn(context, message) methods works — a @centient/logger instance satisfies it directly, but @centient/logger is not a runtime dependency of the SDK.

What gets logged:

  • debug — each retry: attempt number, delay, error class, HTTP method + path
  • warn — retries exhausted, and request timeouts (TimeoutError)

Everything is sanitized before it reaches the logger: HTTP method and pathname only. Headers (X-API-Key, Authorization), request bodies, query strings/fragments, and error messages (which can embed full URLs) are never logged.

Server availability

The SDK is a thin client over the Engram Memory Server REST API. It performs no graceful degradation: there is no local cache, no offline queue, and no fallback path. When engram-server is down, unreachable, or the network is partitioned, every call rejects — you will see a thrown error (connection refused, DNS failure, or a TimeoutError), not a degraded-but-successful result.

The client's only built-in resilience is its retry policy: 5xx and transient network failures are retried with jittered backoff up to the configured attempt cap. That policy is for transient faults — it does not paper over a server that is genuinely down. Any retry, backoff, circuit-breaking, or fallback behavior beyond the built-in retry budget is the caller's responsibility. Wrap calls in your own error handling and decide, per call site, whether to retry, surface the failure to the user, or fail the operation.

The retry backoff is powered by @centient/resilience's createBackoff primitive (linear strategy, 0.5 jitter ratio) — the sleep before retry n is n * retryDelay plus a random jitter in [0, 0.5 * retryDelay). This is the same schedule the SDK has always used; the math now lives in one shared, deterministically-testable place.

isRetryableError(err)

The SDK exports the same predicate it uses internally to decide whether a caught error is worth re-issuing, so you do not have to hand-roll it by matching error messages:

import { isRetryableError } from "@centient/sdk";

try {
  await client.search(sessionId, { query });
} catch (err) {
  if (isRetryableError(err)) {
    // transient: a 5xx server error or a raw transport failure — safe to back
    // off and try again at the application layer.
  } else {
    throw err; // terminal: a timeout, a 4xx, or a deterministic shape/parse
               // failure — retrying cannot change the outcome.
  }
}

Retryable (true): a 5xx EngramError, or a raw transport Error (e.g. a fetch TypeError / ECONNREFUSED). Non-retryable (false): TimeoutError, NetworkError, ResponseShapeError, any 4xx EngramError, and non-Error throwables.

Choosing a different classifier — shouldRetry

Which failures are worth re-issuing is a property of your failure domain, not of the transport, so the classifier is a constructor seam:

import { createEngramClient, isBrownoutTransientError } from "@centient/sdk";

const client = createEngramClient({
  baseUrl,
  apiKey,
  shouldRetry: isBrownoutTransientError,
});

shouldRetry governs every request path — the 5xx gate, the transport catch, and the request timeout. Two invariants hold whatever you inject: retries still caps the attempt count (a predicate widens which failures are retried, never how many times), and a deterministic response-shape failure (a non-JSON 2xx body) is never re-issued.

isBrownoutTransientError is the packaged alternative — the taxonomy a client riding out an engram brownout wants. It differs from the default on exactly two classes:

| Class | isRetryableError (default) | isBrownoutTransientError | |---|---|---| | Request timeout | terminal | retried — the dominant brownout transient | | 5xx (minus the retryable opt-out) | retried | retried | | Transport failure (fetch TypeError, ECONNRESET/ECONNREFUSED/ETIMEDOUT) | retried | retried | | 4xx, including 409 CAS conflicts | terminal | terminal | | Deterministic shape/parse failure | terminal | terminal | | Unknown / generic Error | retried | terminal — may be a non-idempotent partial success |

Adopt it only when every call this client makes is safe to re-issue after a timeout: a POST that timed out may already have been applied server-side, and retrying it duplicates the write. That hazard is why the default is unchanged — the brownout taxonomy is opt-in at 2.x, and the flip is deferred to the next major (#173).

One note if you already use @centient/resilience: its isTransientError preset is shape-based and does not know the SDK's error classes — it reads fetch's bare TypeError as unknown, so wiring it in as shouldRetry would stop retrying real network failures that the SDK retries today. Use isBrownoutTransientError for this seam; isTransientError remains the right default for withRetry() wrapped around SDK calls at the application layer, where the errors it sees are already the SDK's typed ones.

Runtime requirements

  • Node.js >= 20.0.0 (enforced via engines.node in package.json).
  • The per-request timeout and connection-establishment abort are implemented with the global fetch API and AbortController / AbortSignal (client.tsnew AbortController() + setTimeout(...abort)). These are the WHATWG fetch semantics built into Node since the 18.x line; the SDK depends on them being present and standards-conformant, which is why the supported floor is stated explicitly rather than left implicit. Running on an older runtime, or one without a conformant global fetch/AbortSignal, is unsupported — timeouts and aborts will not behave as documented.

engram-server compatibility

This SDK targets the engram-server REST API. The client enforces a single minimum server version at connect time (MIN_SERVER_VERSION, exported from the package); newer resources layer per-feature floors on top of it — calling a feature against a server below its floor 404s (the route does not exist yet), not a silent no-op.

| SDK area | Minimum engram-server | Notes | |---|---|---| | Client floor (all core resources) | 0.31.0 | MIN_SERVER_VERSION; checked by client.checkCompatibility(). | | maintenance.vacuum(), skipEmbedding on crystals.create() | 0.34.0 | Needs the 0.34.0 {success,data} envelope realignment. | | shimmers (/v1/shimmers) | 0.34.0 | Also requires the deployment to set ENGRAM_SHIMMER_ENABLED=true. | | consolidationEvents (/v1/consolidation-events) | 0.41.0 | Public consolidation lifecycle (engram-server #938/#939); the two write actions (consolidate, undo) require a write-scoped key. | | invitations (/v1/invitations) | 0.50.0 | ADR-044 invite/provisioning/connection lifecycle. The 3 redeem routes (redeemPreview, accept, decline) are public — call them from a client with no apiKey/userId (the token is the credential). reveal.token (create/resend) and key.value (accept) are one-time secrets, never re-fetchable. | | health() / healthDetailed() / healthReady() union shapes | 0.50.0 | The three health routes return discriminated unions on status/ready (engram-server #1175), and BOTH 200 and 503 carry the typed body — the SDK resolves a health 503 with the parsed variant (degraded/unhealthy/not-ready) instead of throwing. Older servers return the flat pre-union shapes (e.g. healthDetailed()'s {dependencies, circuitBreakers, rateLimiters}, numeric uptime), which fail the union guards with ResponseShapeError; healthReady() needs the /v1/health/ready union route. healthDetailed()/healthReady() are auth-gated; health() is public. | | Extraction previews (extraction.bootstrapPreview(), extraction.dryRunPreview()) | 0.50.0 | The bootstrap/dryRun mode flags on POST /v1/extraction/extract (engram-server #1167/#1174); write-scoped like the rest of the extraction surface. | | consolidationEvents.queue() (/v1/consolidations/queue) | 0.50.0 | Per-note review-queue rows with composite + coherence/uniqueness/quality score breakdowns — distinct from the event list, which returns event-level aggregates. | | evidence (/v1/evidence) | 0.47.0 | ADR-042 D3 append-only evidence series (engram-server #1035). Dedup-aware append (append/get/listBySeries/listByEntity/listByDescriptor); a same-dedupKey, differing-bodyDigest append throws EvidenceDedupConflictError (409, never last-write-wins). seq is a decimal string; payload is opaque JSONB. The append mutation requires a write-scoped key. | | Incremental listing — updatedAfter / createdAfter / cursor on crystals.list() | 0.45.0 | engram-server ADR-040 (#995) watermarks composed with the #925 keyset cursor. Against an older server the watermark params are ignored (the query returns the unfiltered set) and no cursor comes back — check nextCursor rather than assuming keyset mode took effect. See Incremental listing below. | | crystals.createWithDisposition()meta.disposition | 0.66.0 | The create-path disposition block (engram-server #1594). The json_content and kill_switch decline reasons need 0.67.0 (#1641/#1647). Against an older server the call still succeeds and returns disposition: undefined — never a fabricated "created" — so expectCreated() throws rather than passing an unverifiable write. See Create disposition below. | | dedupMode on crystals.create() / createWithDisposition() | 0.66.0 | The create-path dedup mode (engram-server #1594; default corrected to warn by #1641). "bypass" is the only value that guarantees a mint — the prevent half of the pair whose detect half is meta.disposition. "warn" (the default) does not always mint: above cosine 0.95 it still returns the incumbent as deduplicated. Against an older server the request schema drops the field and the create behaves exactly as before: a no-op, not an error, and notably not a guaranteed mint. See Guaranteeing a mint below. | | Session continuity — ifExists / resolution / binding / lookup() | 0.66.0 | engram-server #1592. ifExists: "resume" on sessions.create*(), the resolution sibling on the create and on sessions.lookup(), the binding sibling on a note create, and the sessionId/sessionExternalId/projectPath identity fields on finalize(). Against an older server ifExists is ignored (a repeat start is still a 409), every one of those fields is reported as undefined rather than fabricated, and sessions.lookup() 404s because the route does not exist. See Session continuity below. | | includeTotal: false on crystals.list() | 0.47.0 | engram-server #1000 — skip the full-matched-set COUNT. An older server ignores the param and counts anyway; the SDK reports whatever arrived, so read total as possibly-undefined rather than assuming the opt-out took effect. See Skipping the COUNT below. |

Tested range (@centient/sdk 2.1.x): floor 0.31.0 → tested upper edge 0.47.0 (centient's G3 integration gate). engram-server main is 0.49.1; versions between the tested upper edge and main are expected to work but are not covered by the SDK's integration gate.

Features

  • 13+ resource classes covering sessions, notes, crystals, entities, search, and more
  • 95+ fully typed request/response interfaces
  • Factory function createEngramClient() for quick setup
  • Session coordination (constraints, decision points, branches)
  • Knowledge crystal management with hierarchy and versioning
  • Entity extraction and graph queries
  • Real-time event streaming
  • Export/import with conflict resolution

Incremental listing (crystals.list())

Requires engram-server >= 0.45.0 (ADR-040 / #995 watermarks, #925 keyset cursor). Purely additive — existing offset callers are unaffected.

Offset pagination is unsound over a corpus that changes between requests: pages shift underfoot, so a deep scan can repeat or skip rows. crystals.list() now exposes the server's two primitives for reading a churning corpus correctly.

  • updatedAfter / createdAfter — ISO-8601 watermarks. The server returns only rows whose updated_at / created_at is strictly after the instant. This is the poll window: "what changed since I last looked?".
  • cursor / nextCursor — an opaque keyset cursor over (createdAt, id), a total order. Stable under concurrent writes, which is exactly what offset pages are not.
let watermark = "1970-01-01T00:00:00.000Z";

async function poll() {
  const pollStartedAt = new Date().toISOString();
  let cursor: string | undefined;

  do {
    const page = await client.crystals.list({
      tags: ["ingest"],
      updatedAfter: watermark,
      cursor,
      limit: 200,
    });
    await handle(page.crystals);
    cursor = page.nextCursor;      // undefined ⇒ window drained
  } while (cursor);

  // Advance the watermark ONLY here — between polls, never per page.
  watermark = pollStartedAt;
}

Three rules the surface enforces or documents, in the order you are likely to trip over them:

  1. Advance the watermark between polls, never per page. Timestamps are not unique. Moving the watermark to the last row you saw silently drops every remaining row that ties on that timestamp — the classic watermark bug. The cursor is the pagination; the watermark is the window.
  2. cursor and offset are mutually exclusive. They are typed as a union, so passing both is a compile error; a plain-JS caller gets a VALIDATION_INPUT_INVALID EngramError before any request is made. (The server resolves the conflict by silently ignoring offset — the SDK refuses rather than degrading quietly.)
  3. Watermarks must be real instants that carry a timezone. Z or ±HH:MMnew Date().toISOString() is exactly right. Both halves are checked client-side and raise a typed error rather than an opaque server 400: a zone-less instant (a watermark must not change meaning with the server's timezone), and an impossible one (2026-02-30T00:00:00Z). The second matters more than it looks — new Date("2026-02-30T00:00:00Z") does not fail, it silently becomes March 2 — so the SDK round-trips the parsed instant against the string you passed and rejects any mismatch.

Results stay ordered by createdAt DESC, id DESC even for an updatedAfter query, so if you prefer a data-derived watermark over the poll start time, take the max updatedAt seen rather than the last row's.

total still reflects the filtered window (watermarks participate in the COUNT); the cursor never changes total, only which rows this page carries.

Skipping the COUNT (includeTotal)

Requires engram-server >= 0.47.0 (#1000). total comes from a full-matched-set COUNT that visits every matching row — unbounded by limit, so a broad list aggregates the whole corpus on every request. A keyset consumer never needs it: completeness comes from hasMore / nextCursor.

const page = await client.crystals.list({
  tags: ["ingest"],
  cursor,
  limit: 200,
  includeTotal: false,   // no COUNT
});
page.total;              // number | undefined — undefined once the server skips it

Opting out changes the return type, deliberately. The server omits meta.pagination.total when it skips the COUNT, so total widens to number | undefined on that overload and the SDK returns the absence. It will not substitute the page length, which would report a single short page as the entire matched set — the same reason the group membership reads expose no total at all.

Two consequences worth knowing:

  • Omit the param for the old behavior. The default is unchanged: no includeTotal means the COUNT runs, total is a plain number, and offset paginators are untouched. An explicit includeTotal: true is also a number.
  • A boolean you compute at runtime widens the type too. The compiler cannot tell whether you opted out, so it hands you number | undefined — as does a params object typed as ListKnowledgeCrystalsParams. Narrow it, or pass the literal.

Create disposition (crystals.createWithDisposition())

POST /v1/crystals does not always mint. It answers 201 for three different outcomes and reports which one on the response envelope's meta.disposition:

| decision | What happened | |---|---| | created | A new crystal row was minted. | | consolidated | The write was merged into an existing crystal. No new row exists, and the returned crystal is the incumbent, at an id you never asked for. | | deduplicated | An exact duplicate already existed and was returned untouched. |

crystals.create() unwraps data and discards meta, so all three look identical to the caller. That blindness is what let 485 templated machine writes consolidate unnoticed in the engram-server#1641 incident — the server reported every one of them, and the SDK dropped the report.

createWithDisposition() issues the identical request and keeps the block:

const { crystal, disposition } = await client.crystals.createWithDisposition({
  nodeType: "finding",
  title: `review result for PR #${pr}`,
  contentInline: JSON.stringify(result),
});

if (disposition?.decision !== "created") {
  log.warn("create did not mint a row", { disposition });
}

disposition.consolidationDeclined says why a near-duplicate minted anyway — content_limit, dedup_mode_warn (the default mode since #1641), incumbent_missing, consolidation_failed, json_content (a structured document on either side, refused even when consolidation was explicitly requested), or kill_switch (the instance-wide ENGRAM_CRYSTAL_CONSOLIDATION_ENABLED=false).

expectCreated() — fail loudly instead of checking

For writers whose record must exist as its own row, where a consolidation is a data-loss event rather than an optimization:

import { expectCreated, CrystalNotMintedError } from "@centient/sdk";

try {
  const crystal = expectCreated(
    await client.crystals.createWithDisposition(params),
  );
} catch (err) {
  if (err instanceof CrystalNotMintedError) {
    err.decision;              // "consolidated" | "deduplicated" | … | null
    err.consolidationDeclined; // reason, when the server reported one
    err.matchedCrystalId;      // the incumbent the write merged into
    err.crystal;               // what the server actually returned
  }
}

err.decision is null when the server sent no disposition: the mint could not be confirmed, which is not the same fact as knowing it happened, and a helper whose whole job is "fail loudly if this was not a mint" must not pass an unverifiable write.

Three deliberate properties:

  • create() is unchanged. Same signature, same return value, same behaviour — including its indifference to meta. Existing callers need no edit; createWithDisposition() is a sibling, not a replacement.
  • An absent disposition is reported as undefined, never as "created". Against a server that does not emit the block you get disposition: undefined — a fabricated "created" is exactly the blindness this surface exists to remove.
  • A malformed disposition throws. A meta.disposition that is present but unreadable is a contract violation and raises ResponseShapeError, like every other wire guard in the SDK. "Not sent" and "sent broken" are different facts.

What "malformed" covers

Every field the CrystalCreateDisposition type declares is validated against its declared type before it is exposed — crystalId and matchedCrystalId as strings, similarity as a finite number, coherence as an object whose mode is a string and whose checked is a boolean. A wrong-typed field (similarity: "0.9", consolidationDeclined: false, coherence: "bad") raises ResponseShapeError rather than reaching a caller that would branch on it.

The one deliberate looseness is closed-union membership. decision, consolidationDeclined, coherence.mode and coherence.degradedReason are checked to be strings and then passed through unchanged, so a value a newer server adds arrives intact instead of throwing on a write path — a new string is not the same fault as a wrong-typed field:

disposition.consolidationDeclined;      // "some_new_reason" — delivered
// { consolidationDeclined: false }     — ResponseShapeError, not a string at all

The exported types say so. Those four fields are declared OpenWireUnion<T> (T | (string & {})), so the declared contract admits exactly what the runtime admits. The known values still autocomplete, but a switch over them cannot compile as if it were exhaustive — because it is not:

switch (disposition.decision) {
  case "created": /* … */ break;
  case "consolidated": /* … */ break;
  case "deduplicated": /* … */ break;
  default:
    log.warn("decision from a newer server", { decision: disposition.decision });
}

expectCreated() is that discipline in one call: it matches "created" positively, so an unrecognized decision fails loudly there rather than passing as a mint. An optional field sent as null is read as absent, and a field the SDK does not know about survives the trip untouched — it is not in the declared type, so it cannot be mistaken for a validated one.

Guaranteeing a mint (dedupMode)

Requires engram-server >= 0.66.0 (#1594). The disposition block tells you a create consolidated. dedupMode stops it from happening — the prevent half of the same pair:

import { expectCreated } from "@centient/sdk";

// This record must exist as its own row. Skip the dedup gate, then assert it.
const crystal = expectCreated(
  await client.crystals.createWithDisposition({
    nodeType: "finding",
    title: `review result for PR #${pr}`,
    contentInline: JSON.stringify(result),
    dedupMode: "bypass",
  }),
);

| dedupMode | cosine > 0.95 | 0.85 ≤ cosine ≤ 0.95 | 0.75 ≤ cosine < 0.85 | |---|---|---|---| | "warn" (server default) | incumbent returned untouched, no new row | mint + _dedupWarning | mint + _dedupWarning | | "consolidate" | incumbent returned untouched, no new row | merge into the incumbent, no new row | mint + _dedupWarning | | "bypass" | mint | mint | mint |

  • "bypass" is the only value that guarantees a mint. It skips the near-duplicate check entirely, which is what a templated machine record needs: one whose identity lives in its tags rather than its prose clears 0.95 against an unrelated sibling on template alone. That is the engram-server#1641 incident — 485 silent consolidations in ~18h.
  • "warn" does not always mint. Read the first column, not the mode name. "warn" mints across the 0.75–0.95 bands with a _dedupWarning attached, but above 0.95 it still returns the incumbent untouched, reporting disposition.decision: "deduplicated" and creating no row. Server-side the exact-duplicate branch is gated only on the mode not being "bypass", so "warn" does not escape it. (One exception: structured content that is not the same record under canonical-JSON equality mints instead, with consolidationDeclined: "json_content".) If your record must exist as its own row, "warn" is not sufficient — use "bypass".
  • "dedup_mode_warn" is band-specific, and is not evidence that "warn" always mints. The server sets that decline reason only in the 0.85–0.95 consolidation band, to say "this could have consolidated, but the mode was warn". It never appears above 0.95, where nothing but "bypass" mints.
  • "consolidate" is a request, not a guarantee. The server refuses it when either side is a JSON document, or when the instance runs with ENGRAM_CRYSTAL_CONSOLIDATION_ENABLED=false, and reports which in disposition.consolidationDeclined. It differs from "warn" only in the 0.85–0.95 band.
  • Omitting the field is a no-op. No dedupMode key is sent and the server's configured default applies (warn on a stock instance). Every existing caller is unaffected.

Two things outrank a per-request dedupMode: the instance-wide ENGRAM_CRYSTAL_CONSOLIDATION_ENABLED=false kill switch (which downgrades to warn, never bypass), and nothing else — the instance's crystalDedup.defaultMode config only supplies the default this field overrides.

The union is closed ("consolidate" | "warn" | "bypass"). Unlike the disposition response fields, dedupMode is never parsed back off the wire, so an unrecognized value is a typo to catch at compile time rather than a newer server's vocabulary to pass through.

Against a server below 0.66.0 the field is dropped by the request schema: the create succeeds and behaves as it always did, so "bypass" is silently not honoured. Pair it with createWithDisposition()/expectCreated() — those still report the truth — or check client.checkCompatibility().

Session continuity (ifExists, resolution and binding)

Session start is the one write every session makes, and a resume is the normal case — a seat restarts, a wake reloads. engram-server#1592 made that answerable, and this SDK surfaces it.

Idempotent start

POST /v1/sessions used to answer 409 for a repeat start, which forced clients into a fallback that opened an unnamed session and orphaned the transcript. ifExists: "resume" returns the existing ACTIVE session with 200 instead:

const { session, resolution } = await client.sessions.createWithResolution({
  externalId: "2026-09-02-session-continuity",
  projectPath: "/repo/centient-sdk",
  ifExists: "resume",
});

const isNew = resolution === "created"; // "resumed" is NOT a new session

One POST replaces a GET-then-POST pair — the "does it already exist?" question is answered by the same write that would otherwise have raced with it. Neither policy reopens a session that is no longer active: a FINALIZED one is refused with 403 SESSION_FINALIZED (ADR-020 immutability) and an ABANDONED one with 403 SESSION_NOT_ACTIVE. Reopen explicitly with sessions.update(id, { status: "active" }).

ifExists is on CreateLocalSessionParams, so the plain sessions.create() sends it too — but only createWithResolution() reports which arm answered.

Where did my note land?

An accepted write into a silently auto-created session is byte-identical to one into the coordinated session: success: true either way. That is the 2026-08-15 orphaned-notes incident — four "successful" saves, and the next day's recall by slug found nothing. binding names the session in the caller's own vocabulary:

const { note, binding } = await client.sessions
  .notes(sessionId)
  .createWithBinding({ type: "decision", content: "..." });

if (binding && binding.sessionExternalId !== expectedSlug) {
  log.warn("note landed in a different session", { binding });
}

Picking the last session back up

sessions.lookup() resolves a session by slug or UUID, with an explicit fallback policy, and reports which session answered and how:

const { session, notes, resolution } = await client.sessions.lookup({
  sessionId: "2026-08-15-fleet-conn-day",
  project: "centient-sdk",   // absolute path or bare name — both resolve
  fallback: "latest",
  includeNotes: true,
});

if (!resolution?.exact) {
  // A fallback hit is ALWAYS exact:false / fallbackApplied:true. It is never
  // presented as the session you named.
  log.warn("picked up a fallback session", { resolution });
}

Three deliberate properties

  • Every pre-existing method is unchanged. create(), the note create() and finalize() keep their signatures, return values and behaviour — the sibling-preserving variants are additions, not replacements.
  • An absent sibling is undefined, never a default. Against a server older than 0.66.0 there is no resolution and no binding, and the SDK says so rather than synthesizing one from the request. "The server did not say" is a different fact from "it was created" / "it landed where you asked".
  • A malformed sibling throws. A binding or resolution that is present but unreadable raises ResponseShapeError, like every other wire guard in the SDK. Every field is checked against the type it declares — an object-valued sibling must be a non-array object (an array is a typeof "object", and admitting one would hand you [] under a declared object type), the lookup's session and each attached note likewise, exact / fallbackApplied booleans, candidatesConsidered a number, and the finalize identity fields strings when present.

The one deliberate looseness is closed-union membership. resolution, binding.sessionStatus and the lookup resolution's match / projectMatch / status are checked to be strings and then passed through unchanged, so a value a newer server adds arrives intact instead of throwing on a path where the write has already happened — a new string is not the same fault as a wrong-typed field:

result.resolution;                    // "reopened" — delivered, not dropped
// { resolution: { arm: "created" } } — ResponseShapeError, not a string at all

The exported types say so. Those five fields are declared OpenWireUnion<T> (T | (string & {})), so the declared contract admits exactly what the runtime admits. The known values still autocomplete, but a switch over them cannot compile as if it were exhaustive — because it is not:

switch (result.resolution) {
  case "created": /* … */ break;
  case "resumed": /* … */ break;
  default:
    log.warn("resolution from a newer server", { resolution: result.resolution });
}

Match positively (resolution === "created") rather than by elimination, so an unrecognized value fails loudly at your branch instead of passing as a mint. The named value sets — SessionCreateResolution, SessionStatus, SessionResolutionMatch, SessionProjectMatch — stay exported and closed, so a caller can still enumerate what this SDK version knows.

finalize() also carries sessionId / sessionExternalId / projectPath from 0.66.0 — the identity a handoff baton's engram_session field must record. Unlike the other two, those ride inside data, so finalize() needed no variant: they are simply typed now.

Real-time event streaming

The events resource subscribes to the server's GET /events SSE stream and delivers parsed, typed EngramEvents in two equivalent modes. Both send the X-API-Key header correctly. Pick whichever fits your control flow.

Pull mode — subscribeIter() (recommended)

An AsyncIterable you drive with for await. The Python SDK exposes the symmetric events.subscribe_iter (engram/resources/events.py).

const ac = new AbortController();

for await (const event of client.events.subscribeIter(
  ["crystal.created", "note.created"],
  { signal: ac.signal, highWaterMark: 512 }
)) {
  console.log(event.type, event.entity_id);
  if (shouldStop) break; // breaking out tears the subscription down
}
// ...or, from elsewhere: ac.abort() ends the loop cleanly.

Backpressure is bounded, never silent: if the server pushes events faster than your loop drains them and the internal buffer exceeds highWaterMark (default 1024), the iterator throws EventStreamOverflowError instead of dropping events. Consume faster, raise highWaterMark, or use the callback API.

Push mode — subscribeWithFetch()

A callback subscription. Returns an EventSubscription; call .close() to stop.

const sub = client.events.subscribeWithFetch(
  ["crystal.created"],
  (event) => console.log(event.type, event.entity_id),
  (err) => console.error("stream error", err)
);

// Later:
sub.close();

Deprecated — subscribe() (EventSource)

subscribe() uses the EventSource API, which cannot send the API key header — the key is silently dropped and authentication fails. It is @deprecated and now throws InsecureEventSourceError by default; it is reachable only with an explicit acknowledgement and only works against unauthenticated endpoints. Prefer subscribeIter() or subscribeWithFetch().

// Throws InsecureEventSourceError:
client.events.subscribe(["crystal.created"], onEvent);

// Explicit opt-in (unauthenticated endpoints only):
client.events.subscribe(["crystal.created"], onEvent, onError, {
  allowInsecureEventSource: true,
});

Documentation

License

MIT