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.decisions | .pending(), .decide(id, { decision, reason? }) — the decision gate (ADR-139): pending decision requests, approve/reject/answer. |
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
Agent Credentials
Two honest ways to credential an AI agent — an instance-level service account that survives personnel changes, or an attenuated sub-grant of a member that dies with the member. When to use which, and what is enforced today.
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.