TypeScript SDK
Integrate with Syncropel from JavaScript or TypeScript — emit records, query threads, enforce grammar, fail open on transport errors. Works in Node, Deno, Bun, edge runtimes, and modern browsers.
Install
npm install @syncropel/sdk
# or: pnpm add / yarn add / bun addRequires Node 18+ or any runtime with a global fetch. Zero native dependencies.
Hello, Syncropel
Start a Syncropel instance locally (spl serve), then:
import { Client, Identity, Ref } from "@syncropel/sdk";
const client = new Client({
endpoint: "http://localhost:9100",
identity: Identity.static("did:example:my-app"),
});
const result = await client.emit({
act: "PUT",
kind: "music.catalog.track",
body: { title: "Glow", artists: ["Zonke"] },
refs: [Ref.musicTrack({ isrc: "USJI19810404" })],
thread: "th_library",
});
console.log(result.success, result.recordId);One call validates the body.kind grammar, builds the 8-field record envelope, retries on transient 5xx and network errors, and resolves cleanly on transport failures — so a flaky network never crashes your handler.
What you get
- Universal runtime — Node, Deno, Bun, edge runtimes, browsers. Uses platform
fetch. - Async client —
emit,query,queryThread,intend,fulfill, plus reserved-kind helpers - Files & blobs —
client.data.*reads and writes the data plane (see Files & blobs) - Folds & live threads —
client.folds.resolve()reads any fold;client.threads.watch()streams a thread live - Grammar enforcement —
body.kindvalidated client-side before any network call - Canonical references — 11 built-in constructors for cross-publisher correlation
- Fail-open reads, surfaced errors — record reads fail open (a flaky instance never crashes your UI); malformed requests and data-plane writes raise so a dropped write is never mistaken for success
- Identity-aware — records carry the configured DID
MockKernel— in-memory fake at@syncropel/sdk/testingfor adapter unit tests- Companion types — pair with
@syncropel/projectionswhen you're working with rendered document schemas
Core API
Client
| Method | Purpose |
|---|---|
emit(opts) | Primary emit. Validates, envelopes, retries on transient errors. Returns EmitResult. Never raises on transport failures. |
intend(opts) | Open a thread with an INTEND record. Generates a thread id if none supplied. |
fulfill(opts) | Close a thread with a KNOW record. fulfills accepts one record id or an array. |
queryThread(opts) | All records in a thread. Fail-open (returns [] on transport error). |
query(opts) | Filtered records by kind/actor/thread. Fail-open. |
health() | Server health probe. Fail-open. |
close() | No-op today (kept for API stability); reserved for pooled-client variants. |
Constructor options (ClientOptions):
new Client({
endpoint?: string, // default: http://localhost:9100
identity: Identity, // required
timeoutMs?: number, // per-request timeout
maxRetries?: number, // transient 5xx/network retry count
backoffMs?: number, // base backoff between retries
onEmit?: (result) => void, // observation hook (fires after every emit)
apiKey?: string, // bearer token for authenticated instances
fetch?: typeof fetch, // custom transport (use for testing)
});Namespaces
Beyond the flat emit/query methods, the rest of the surface is grouped into namespaces on the client:
| Namespace | What it covers |
|---|---|
client.records | .create(opts) (emit alias), .get(id), .richQuery({ filter, sort?, limit? }) — server-side filtered query. |
client.threads | .list(), .records(id), .state(id), .project(id, format), and .watch(id) — a live AsyncIterable of thread events (see below). |
client.folds | .resolve<V>(name, cacheKey?) reads any fold through one typed method; .watermark(name, key) checks liveness without recomputing. |
client.data | Files & blobs — write (with compare-and-swap), read, list, stat, publish, materials. See Files & blobs. |
client.namespaces | .list(), .create({ id, description?, policy? }). |
client.federation | .peers(), .pair(peer), .sync(thread, peer?), .discoverPeers(), .mergeResults(). |
client.invites | issue / list / preview / redeem / revoke / audit invitations. |
client.graph | .query({ start, edges?, depth?, filter? }) — graph queries over your instance. |
client.expr | .eval({ expression, context, record? }) — evaluate a CEL expression server-side. |
client.aitl | .pending(), .decide(id, { decision, reason? }) — actor-in-the-loop proposals. |
Reading folds
A fold is a derived view computed from a thread's (or the whole instance's) records — the turn timeline, thread state, trust, and more. Read any of them through one method:
// per-thread fold: pass the thread id as the cache key
const turns = await client.folds.resolve("turn", threadId);
render(turns.value);
// global fold: omit the cache key
const trust = await client.folds.resolve("trust");
// liveness check — has the fold moved since you last read it?
const wm = await client.folds.watermark("turn", threadId);resolve fails open to an empty fold (watermark: -1, value: null) on a transport error.
Live thread streaming
client.threads.watch() returns an AsyncIterable over a thread's live events — auto-reconnecting with resume-from-clock, falling back to polling after repeated disconnects, cancellable via AbortSignal:
for await (const event of client.threads.watch(threadId)) {
if (event.type === "record") render(event.record);
// also: "scribe.delta", "connected", "disconnected"
}Prefer watch() for new code; the older callback-based client.subscribe(thread, cb) remains for compatibility.
Identity
Identity.static("did:example:my-app") // Available today
Identity.key(pathOrBytes) // Planned — raises if called
Identity.federated(...) // Planned — raises if calledReserved-kind helpers
For records that participate in the protocol's coherence story:
await client.emitCorrection({
corrects: [recordId],
revisedFields: { title: "Glow (Remastered)" },
reason: "updated metadata",
thread: "th_library",
});
await client.emitErasure({
erases: [recordId],
reason: "GDPR request",
thread: "th_library",
});
await client.emitAlias({
oldKind: "music.song",
newKind: "music.catalog.track",
reason: "grammar migration",
thread: "th_library",
});
await client.emitScopeTransfer({
scope: "music.catalog",
fromPublisher: "did:web:old-label.example.com",
toPublisher: "did:web:new-label.example.com",
reason: "catalog transfer",
thread: "th_library",
});
await client.emitScopeClaim({
scope: "music.catalog",
governancePolicy: { versioning: "semver" },
thread: "th_library",
});Canonical references (Ref)
Canonical refs let records about the same real-world entity correlate across apps:
Ref.musicTrack({ isrc: "USJI19810404" }) // → @music.track
Ref.codeFile({ repo: "acme/myproject",
path: "src/main.rs" }) // → @code.file
Ref.opsIncident({ pagerduty: "ABC123" }) // → @ops.incident
Ref.calEvent({ uid: "abc@google.com" }) // → @cal.event
Ref.socialPerson({ email: "alice@example.com" }) // → @social.person
Ref.mediaPhoto({ sha256: "..." }) // → @media.photo
Ref.mediaVideo({ youtube: "abc123" }) // → @media.video
Ref.docText({ doi: "10.1234/example" }) // → @doc.text
Ref.finTransaction({ stripe: "ch_abc123" }) // → @fin.transaction
Ref.researchPaper({ arxiv: "2401.12345" }) // → @research.paper
Ref.coreThread({ id: "th_abc123" }) // → @core.threadTypes
type ActType = "GET" | "PUT" | "CALL" | "MAP" | "INTEND" | "DO" | "KNOW" | "LEARN";
type DataType = "SCALAR" | "FORMULA" | "DISTRIBUTION" | "REFERENCE" | "MORPHISM" | "VOID";
interface CanonicalRef { kind: string; id: string; }
interface EmitResult {
success: boolean;
recordId?: string;
clock?: number;
error?: string;
retried: number;
kind: string;
act: string;
thread: string;
}Authenticating to a remote instance
When the Syncropel instance has bearer-token auth enabled, pass the token via apiKey:
const client = new Client({
endpoint: "https://your-host.example.com:9100",
identity: Identity.static("did:example:my-app"),
apiKey: process.env.SPL_TOKEN,
});The client sends Authorization: Bearer <token> on every outbound request. See Authentication & Service Accounts for how to mint the token.
Inference — client.infer()
client.infer() emits an infer.query.v1 INTEND and (by default) polls until the KNOW lands. The full query anatomy lives in the Inference guide; this section is the SDK surface.
import { Client } from "@syncropel/sdk";
const client = new Client({ endpoint: "http://localhost:9100" });
const result = await client.infer({
input: { goal: "Summarise this paper in one paragraph" },
responders: [
{ kind: "llm", model: "~sonnet" },
{ kind: "llm", model: "~gpt-4o" },
],
fold: { function: "consensus", min_quorum: 2 },
answer_shape: { kind: "core.summary.v1" },
side_effects: { max_cost_usd: 0.30, max_latency_secs: 60 },
});
console.log(result.answer);
console.log(`Trust: ${result.trust_summary.mean.toFixed(2)}`);
console.log(`Cost: $${result.cost_actual_usd.toFixed(4)}`);The InferRequest shape mirrors the schema field-for-field. TypeScript types for every sub-shape come from @syncropel/config:
import type {
InferQueryV1,
ResponderPredicate,
Fold,
Orchestration,
SideEffects,
} from "@syncropel/config";Fire-and-forget
Pass { wait: false } to emit the INTEND and return immediately:
const pending = await client.infer(request, { wait: false });
console.log(pending.correlation_id, pending.thread_id);You get back InferPending = { pending: true; correlation_id: string; thread_id: string }. Poll the thread yourself via client.threadRecords(pending.thread_id) or subscribe for the KNOW.
Options
await client.infer(request, {
wait: true, // default
poll_interval_ms: 500, // default
poll_timeout_ms: 300_000, // default — 5 minutes
thread: "th_abc123...", // optional explicit thread
});Result shape
interface InferResult {
answer: unknown;
provenance: Array<{
responder_did: string;
response_record_id: string;
weight: number;
match_level: "L0" | "L1" | "L2" | "L3" | null;
trust_at_dispatch: number;
}>;
trust_summary: { mean: number; min: number; max: number; n_contributors: number };
cost_actual_usd: number;
fold_function: string;
chosen_response_id?: string;
know_record_id: string;
thread_id: string;
}Error handling — fail-hard (not fail-open)
Unlike client.emit(), client.infer() fails hard:
- Invalid request shape throws before any HTTP call.
- Network or instance error on INTEND emission throws.
- Poll timeout throws
TimeoutError.
Catch timeouts explicitly if you want the thread id preserved for manual polling.
Error model
The SDK distinguishes two failure modes:
- Fail-open — record reads (
query,queryThread,richQuery,health,folds.resolve) return an empty result on a transient failure (a briefly-unavailable instance, a5xx, an auth blip), so a UI never crashes on backend trouble. - Surfaced (thrown) — a programmer error or a write that must not be lost throws, so it can't be mistaken for an empty result:
| Error | Thrown when |
|---|---|
SyncropelKindError | body.kind violates the grammar — raised before any network call. |
SyncropelQueryError | A read is rejected as malformed (HTTP 4xx) — e.g. a bad filter or sort. Carries .detail. |
SyncropelDataError | A client.data.* operation fails — a quota, a permission denial, or a compare-and-swap conflict (.isConflict). |
import { SyncropelQueryError } from "@syncropel/sdk";
try {
const { records } = await client.richQuery({ filter: { thread }, sort: { clock: -1 } });
} catch (e) {
if (e instanceof SyncropelQueryError) {
// the request was malformed (4xx) — fix the filter/sort, don't treat as "empty"
}
}Migrating from 0.7.x: richQuery / query / queryThread used to swallow a malformed 4xx into an empty result. As of 0.8.0 they throw SyncropelQueryError instead. Valid queries are unaffected; wrap any call that might send a malformed filter/sort in a try/catch. The canonical fix for the most common case is the object sort form { clock: -1 } (not a string).
Testing without an instance
Use MockKernel to write unit tests against the SDK interface without running spl serve:
import { Client, Identity, Ref } from "@syncropel/sdk";
import { MockKernel } from "@syncropel/sdk/testing";
test("my adapter emits a track", async () => {
const kernel = new MockKernel();
const client = new Client({
endpoint: "http://mock",
identity: Identity.static("did:example:test"),
fetch: kernel.fetch,
});
await myAdapter(client);
const tracks = kernel.recordsByKind("music.catalog.track");
expect(tracks).toHaveLength(1);
expect(tracks[0].body.title).toBe("Glow");
});Useful MockKernel helpers: recordsIn(thread), recordsByKind(kind), recordsByActor(did), count(), clear(), failNextPostCall(n), failNextGetCall(n) (for transport-failure testing).
Running on edge runtimes
Because the SDK uses global fetch, it runs unmodified on edge runtimes. Pass the runtime's environment bindings explicitly if you're using them:
export default {
async fetch(request: Request, env: Env) {
const client = new Client({
endpoint: env.SYNCROPEL_ENDPOINT,
identity: Identity.static(env.APP_DID),
apiKey: env.SPL_TOKEN,
});
await client.emit({
act: "KNOW",
kind: "ops.request.observed",
body: { path: new URL(request.url).pathname },
thread: "th_ops",
});
return new Response("ok");
},
};What's next
- Files & blobs — read and write the data plane with
client.data.* - Records concept — the 8-field envelope and why it's content-addressed
- Threads concept — how records group into workflows
- Projections SDK — schema + validators for rendered documents you emit or consume
- React components — UI atoms that render projection documents
- Python SDK — the equivalent package for Python code
- Authentication & Service Accounts — securing the instance your code talks to
Universal traversal — your reach
See everything you reach across all your instances as one continuous view, ask graph questions about the people and threads connected to you, and understand how shared threads appear with exactly the access that was granted.
Files & blobs (data plane)
Read and write files programmatically with the TypeScript SDK — the three-step write with compare-and-swap, streaming reads, directory operations, publishing durable artifacts, and material search.