ZeroLock TypeScript SDK

@zlock/sdk-ts is a thin, typed client over the gateway's HTTP and WebSocket protocols. It holds no correctness logic of its own — every guarantee ZeroLock makes is enforced by the gateway, and the SDK's job is limited to sending well-formed requests, surfacing typed errors, and implementing a handful of documented protocols (retry, caching, task claiming) on top of the raw wire calls.

Every code sample and signature below was checked directly against packages/sdk-ts/src/*.ts — not written from the wire protocol's intent, but from the actual exported functions and their actual parameter names.

Design Principles

  • Stateless client. A ZeroLockClient instance holds configuration (gateway URL, agent ID, timeout, retry policy) and nothing else — it never caches a shard's value unless you explicitly opt into CachedZeroLockClient, and it never assumes a previous call's result is still true. This mirrors the architecture's "gateway is the authority" principle: the SDK deliberately avoids becoming a second source of truth.
  • Types describe the schema, not the other way around. The exported TypeScript types (ShardType, CrdtType, ShardValue<T>, etc.) are generated from — or checked against — the same shared schema package the Python SDK validates against, so a shard's shape can't silently drift between what TypeScript and Python agents expect.
  • Errors are typed, not stringly. Every failure mode the gateway can return (404, 409, 403, network failure) has a corresponding exported error class, so err instanceof StaleCommitError is how you branch, not string-matching a message.

Installation

bash
npm install @zlock/sdk-ts@beta
# or
pnpm add @zlock/sdk-ts@beta

Quick Start

typescript
import { ZeroLockClient } from '@zlock/sdk-ts';
 
const client = new ZeroLockClient({
  gateway: 'http://localhost:8080',
  agentId: 'my-agent',
});
 
// Create a shard
await client.createShard('counter', {
  type: 'decision',
  initialValue: { count: 0 },
});
 
// Read and update
const shard = await client.getShard<{ count: number }>('counter');
await client.commitShard('counter', { count: shard.value.count + 1 }, shard.version);

Client Configuration

typescript
interface ZeroLockClientConfig {
  /** Gateway URL */
  gateway: string;
 
  /** Agent ID (auto-generated as `agent-<8 random chars>` if not provided) */
  agentId?: string;
 
  /** Request timeout in ms (default: 30000) */
  timeout?: number;
 
  /** Custom fetch implementation */
  fetch?: typeof fetch;
 
  /** Retry configuration */
  retry?: Partial<RetryConfig> | false;
 
  /** API key, sent as the `x-api-key` header (needed when the gateway uses auth) */
  apiKey?: string;
 
  /** Groups this agent's operations into one run in traces */
  runId?: string;
 
  /** Links gateway events to a span in your own tracing system */
  traceId?: string;
 
  /** Free-form key/value metadata attached to every audit event from this client */
  metadata?: Record<string, string | number | boolean>;
}

The fetch override exists specifically to make the client testable: pass a mock or instrumented fetch implementation in unit tests instead of hitting a real gateway — see Testing below.

Authentication and Trace Context

typescript
const client = new ZeroLockClient({
  gateway: 'https://gateway.example.com',
  agentId: 'planner-1',
  apiKey: process.env.ZEROLOCK_API_KEY,     // sent as x-api-key on every request
  runId: 'run-2026-10-08-001',              // groups this agent's events in traces
  traceId: currentSpan.traceId,             // lets you join gateway events to your own spans
  metadata: { step: 'plan', attempt: 1 },   // shows up as client_metadata in the audit log
});
 
// Start of a new run: change the run/trace context, keep the same key
client.setContext({ runId: 'run-2026-10-08-002', traceId: newTraceId });

The gateway stores runId, traceId and metadata in the metadata of every audit entry written while handling that request (run_id, trace_id, client_metadata), including conflicts. They are optional and never affect correctness: invalid or oversized values are dropped, not rejected. ApprovalClient, ZeroLockWSClient and CachedZeroLockClient accept the same apiKey/runId/traceId options; build siblings from one client with client.getContext().

The API key travels in a header, never in the URL. The WebSocket client sends it as a handshake header. Node 22+ has a built-in WebSocket that can do this. On Node 18 or 20, install the ws package and pass it in: new ZeroLockWSClient({ ..., webSocket: WebSocket }) with import WebSocket from 'ws' (CachedZeroLockClient takes the same option). Without an implementation the client throws No WebSocket implementation found. Browsers cannot set handshake headers, so use a gateway without auth, or a proxy that adds the key, for browser WebSocket connections.

Retry Configuration

typescript
const client = new ZeroLockClient({
  gateway: 'http://localhost:8080',
  retry: {
    maxRetries: 5,        // default: 3
    initialDelayMs: 100,  // default: 100
    maxDelayMs: 10000,    // default: 5000
    backoffMultiplier: 2, // default: 2
    retryableStatuses: [502, 503, 504, 429], // this is the actual default list
    jitter: true,         // default: true
  },
});
 
// Or disable retries entirely
const client = new ZeroLockClient({
  gateway: 'http://localhost:8080',
  retry: false,
});

Retry only wraps getShard and createShard internally — it does not wrap commitShard or appendItem; those two go straight to the gateway with no automatic retry, because a commit's retryability depends on why it failed (see below), which the generic HTTP retry layer can't know. Note that retryableStatuses deliberately does not include 409 — a stale-version conflict is not a transient failure to retry blindly, it's a signal that your new_value was computed from data that's since changed, and retrying the identical request would just fail again. Handle 409 (via StaleCommitError) with a read-modify-commit loop instead — see Handling Conflicts.


Shard Operations

Create Shard

typescript
await client.createShard('my-shard', {
  type: 'decision',           // or 'additive'
  initialValue: { key: 'value' },
  reversible: true,           // default
  ownerPolicy: 'multi-writer', // decision shards only
  valueSchema: {              // optional JSON Schema
    type: 'object',
    properties: {
      key: { type: 'string' }
    }
  }
});
 
// For additive shards
await client.createShard('task-queue', {
  type: 'additive',
  crdtType: 'map',  // 'map' or 'array' for commits today; 'text' can be created but not committed to — see Architecture
});

Read Shard

typescript
const shard = await client.getShard<MyType>('my-shard');
console.log(shard.value);   // MyType
console.log(shard.version); // number

getShard retries transient failures per your retry config (see above); it throws ShardNotFoundError on a 404 rather than retrying it.

Commit Update (Decision Shards)

typescript
try {
  const result = await client.commitShard(
    'my-shard',
    { key: 'new-value' },
    currentVersion,
    ['dependency-shard'] // optional read set
  );
  console.log('New version:', result.version);
} catch (err) {
  if (err instanceof StaleCommitError) {
    console.log('Conflict! Current version:', err.currentVersion);
  }
}

commitShard is only for decision shards and additive map shards — see the next section for additive array shards, which use a different method entirely, not commitShard with a workaround payload.

Append to an Additive Shard

typescript
// Additive ARRAY shards: use appendItem, not commitShard
await client.appendItem('findings-log', { question: 'q-1', by: 'researcher-3' });

appendItem is a true CRDT push, not a read-modify-write: it never throws StaleCommitError, has no expectedVersion parameter at all (there is nothing to check a version against), and every concurrent caller's item survives regardless of who else is appending to the same shard at the same time — this was verified live at up to 80 concurrent appenders with zero loss. Calling appendItem against a shard that isn't an additive array shard throws GatewayError with a 400 UNSUPPORTED_DIFF status.

For additive map shards, keep using commitShard — the object you pass is applied as a grow/update-only patch (absent keys are left alone, a key set to null is deleted); see Architecture — Additive Shards for exactly why.


WebSocket Client

Connection

typescript
import { ZeroLockWSClient } from '@zlock/sdk-ts';
 
const ws = new ZeroLockWSClient({
  wsUrl: 'ws://localhost:8080/ws',
  agentId: 'my-agent',
  autoReconnect: true,    // default: true
  reconnectDelay: 1000,   // default: 1000ms
});
 
await ws.connect();

autoReconnect handles the socket-level reconnect, but subscriptions do not automatically survive a reconnect — re-issue ws.subscribe(...) for anything you need to keep watching after a disconnect/reconnect cycle, typically from the same place you originally subscribed (or from a setOnStateChange callback watching for the transition back to 'connected').

Subscriptions

typescript
// Subscribe to shard updates
ws.subscribe('my-shard');
 
// Handle invalidation (Layer 2)
ws.setOnInvalidate((shardId, version, changedBy) => {
  console.log(`Shard ${shardId} updated to v${version} by ${changedBy}`);
  // Invalidate local cache
});
 
// Handle conflicts (Layer 3)
ws.setOnConflict((shardId, currentVersion, winning, rejected, diff) => {
  console.log(`Conflict on ${shardId}`);
  console.log('Winning:', winning);
  console.log('Rejected:', rejected);
  // Implement resolution logic
});
 
// Unsubscribe
ws.unsubscribe('my-shard');

CRDT Sync

typescript
import * as Y from 'yjs';
 
// Request sync for additive shard
const doc = new Y.Doc();
const stateVector = Y.encodeStateVector(doc);
const response = await ws.requestSync('crdt-shard', stateVector);
 
// Apply update (response.update is base64 - the client decodes it for you
// internally when routing to setOnSyncUpdate; requestSync's own resolved
// value is the raw SyncResponseMessage, update field still base64-encoded)

requestSync resolves or rejects within 10 seconds (hardcoded) — a shard ID with no response inside that window rejects with a timeout error rather than hanging indefinitely.


Cached Client (Layer 2)

typescript
import { CachedZeroLockClient } from '@zlock/sdk-ts';
 
const client = new CachedZeroLockClient({
  gateway: 'http://localhost:8080',
  agentId: 'my-agent',
  cache: {
    maxEntries: 1000,  // default: 1000
    ttlMs: 60000,      // default: 0 (no TTL - entries only go stale via invalidate)
  },
  wsUrl: 'ws://localhost:8080/ws', // required for automatic invalidation
  autoConnectWs: true,              // connect the socket immediately on construction
});
 
// Automatically uses cache for reads, and subscribes to invalidation for
// every shard it reads
const shard = await client.getShard('my-shard');
 
// Cache stats
const stats = client.getCacheStats();
console.log('Hit rate:', stats.hitRate);

wsUrl and autoConnectWs are not optional extras — without them, this client's cache is never invalidated by other agents' writes. CachedZeroLockClient will happily serve stale cached reads forever (or until a ttlMs you set expires) if you construct it with only gateway/agentId and no wsUrl; it silently has no way to learn that another agent committed a change. If you don't need real-time invalidation and are relying on a short ttlMs instead, that's a valid choice — just make it deliberately, not by omission.

CachedZeroLockClient only ever affects reads. Every commitShard call still goes straight to the gateway with a real version check — the cache can make a read stale between invalidations, but it can never make a write unsafe, because the gateway rejects any commit built on a version that's no longer current regardless of what the local cache believed. appendItem is not exposed on CachedZeroLockClient today — call it on a separate plain ZeroLockClient instance if you need both caching and additive appends against the same gateway.

Listing, deleting and telemetry

typescript
const page = await client.listShards({ limit: 50 });          // { shards, nextCursor }
const next = await client.listShards({ after: page.nextCursor! });
await client.deleteShard('old-shard');                         // throws ShardNotFoundError if missing
 
// Observe every HTTP request (hooks that throw are ignored)
const traced = new ZeroLockClient({
  gateway: 'http://localhost:8080',
  telemetry: {
    onRequest: ({ method, url }) => span.addEvent(`${method} ${url}`),
    onResponse: ({ status, durationMs, error }) => metrics.observe(durationMs, { status, error }),
  },
});
 
// Report your own events into the gateway trace (best-effort, returns false on failure)
await client.reportEvent('claim_won', 'task-queue', { task_id: 'task-1' });

claimTask(..., { reportEvents: true }) reports claim_attempt and claim_won/claim_lost for you.

For tasks on an additive map, pass claimLog: 'my-claims' (an existing additive array shard) to claimTask. The first log entry for a task decides its owner, so exactly one agent wins even if a slow agent acts on a stale read. Without it, claiming on an additive map is last-write-wins and a late claim can overwrite a completed task.


Task Claiming

typescript
import { claimTask, completeTask, releaseTask, addTask } from '@zlock/sdk-ts';
import { ZeroLockClient } from '@zlock/sdk-ts';
 
const client = new ZeroLockClient({ gateway: 'http://localhost:8080', agentId: 'my-agent' });
 
// Add task to queue - takes the plain HTTP client, not a WebSocket client
await addTask(client, 'task-queue', 'task-1', { type: 'process-data', payload: { /* ... */ } });
 
// Claim task (write-wait-reread protocol)
const result = await claimTask(client, 'task-queue', 'task-1', 'my-agent');
if (result.claimed) {
  // Process task...
 
  // Mark complete
  await completeTask(client, 'task-queue', 'task-1', 'my-agent');
} else {
  console.log('Task already claimed by:', result.owner);
}

All four helpers — claimTask, releaseTask, completeTask, addTask — take a ZeroLockClient (the plain HTTP client) as their first argument, not a ZeroLockWSClient. claimTask returns TaskClaimResult ({ claimed: boolean, owner: string, version: number }) — there is no .success or .claimedBy field; check .claimed and .owner. completeTask/releaseTask return a plain boolean; addTask returns the new version number.

typescript
interface TaskClaimResult {
  claimed: boolean;
  owner: string;   // the agent that ends up owning the task, whether that's you or someone else
  version: number;
}

claimTask implements all four steps of the write-wait-reread protocol described in Architecture — Task Claiming Protocol internally, and retries on StaleCommitError up to maxRetries (default: 3) times with a linear backoff (retryDelay * (attempt + 1), default retryDelay: 50ms). If result.claimed is false, don't retry the same task; move on to the next one in the queue, since another agent has already committed to it.

Known parity gap with the Python SDK, as of this writing: claim_task/release_task/complete_task/add_task on the Python side accept an additive: bool parameter that sends a minimal single-key patch instead of this agent's whole locally-known tasks dict — necessary to avoid clobbering a concurrent agent's write when the queue itself is an additive shard — and release_task/complete_task/add_task retry on StaleCommitError (default 25 attempts) the same way claim_task always has. The TypeScript versions here do not yet have either fix: releaseTask, completeTask, and addTask always send the full tasks object, and none of the three retries on conflict at all — a StaleCommitError on any of them propagates immediately as a thrown exception (releaseTask/completeTask catch it internally and return false; addTask does not catch it and lets it propagate). If you're calling these from TypeScript against a real concurrent workload, either wrap your own retry loop around releaseTask/completeTask/addTask, or — simpler — keep your task queue as a decision shard (the recommended default; see Architecture), where a single conflict is rare enough in practice that you can treat a false/thrown result as "someone else already handled this" rather than something you must retry.


Approvals (Layer 4)

typescript
import { ApprovalClient } from '@zlock/sdk-ts';
 
const approvals = new ApprovalClient({
  gateway: 'http://localhost:8080',
  agentId: 'my-agent',
});
 
// Request approval for irreversible operation
const approval = await approvals.requestApproval({
  shardId: 'payment-shard',
  proposedValue: { amount: 1000 },
  expectedVersion: 2,
  description: 'Process payment',
  ttlSeconds: 3600, // optional, default 1 hour server-side
});
 
console.log('Approval ID:', approval.id);
console.log('Status:', approval.status); // 'pending'
 
// Wait for approval (polling)
const result = await approvals.waitForApproval(approval.id, {
  timeoutMs: 300000,   // 5 minutes; omit for no timeout
  pollIntervalMs: 5000, // default: 1000
});
 
if (result.status === 'approved') {
  // Once approved, retry your original commit with approvalId set:
  await client.commitShard('payment-shard', { amount: 1000 }, 2 /* current version at approval time may differ - re-read if unsure */);
}

requestApproval takes { shardId, proposedValue, expectedVersion, description?, ttlSeconds? } — there is no operation or value field; the operation is always implicitly "commit proposedValue," and the field is called proposedValue, not value. waitForApproval resolves with status: 'rejected' or status: 'expired' rather than throwing, and only throws if timeoutMs is exceeded first — treat all three terminal states (approved, rejected, expired) as valid, expected outcomes in your control flow.

Approval status: 'approved' does not always mean your commit applied. If the shard changed between your requestApproval call and someone calling approve, the gateway attempts the commit at approval time and it can still fail its own version check — the approval stays approved, but the underlying ApprovalResolution won't include a version. Check result.version !== undefined if you need to know whether the operation actually took effect, not just whether the approval itself was granted.


Error Handling

typescript
import {
  ZeroLockError,
  StaleCommitError,
  ShardNotFoundError,
  ShardExistsError,
  GatewayError,
} from '@zlock/sdk-ts';
 
try {
  await client.commitShard('shard', value, version);
} catch (err) {
  if (err instanceof StaleCommitError) {
    // OCC conflict - retry with new version
    console.log('Expected:', err.expectedVersion);
    console.log('Current:', err.currentVersion);
    console.log('Reason:', err.reason); // 'STALE_VERSION' | 'READ_SET_STALE'
  } else if (err instanceof ShardNotFoundError) {
    // Shard doesn't exist
  } else if (err instanceof GatewayError) {
    // Network, server, or approval-gate error
    console.log('Status:', err.statusCode);
  }
}

All exported error classes extend ZeroLockError, so a broad catch (err) { if (err instanceof ZeroLockError) { ... } } is a safe way to distinguish "the gateway responded with a known failure mode" from an unrelated exception (a bug in your own code, a JSON parse error) further up the stack. Note that a 403 APPROVAL_REQUIRED response (see API Reference) surfaces as a plain GatewayError with statusCode: 403, not a dedicated ApprovalRequiredError class — check err.statusCode for that case.


Retry Logic

typescript
import { withRetry, wrapWithRetry } from '@zlock/sdk-ts';
 
// One-off retry
const result = await withRetry(
  () => client.getShard('my-shard'),
  { maxRetries: 5 }
);
console.log(`Success after ${result.attempts} attempts`);
 
// Wrap function for automatic retries
const resilientGetShard = wrapWithRetry(
  (id: string) => client.getShard(id),
  { maxRetries: 3 }
);
 
const shard = await resilientGetShard('my-shard');

withRetry/wrapWithRetry are generic — they retry any thrown GatewayError matching retryableStatuses (or a network-level error, or a fetch AbortError from a timeout), regardless of what function they wrap. They are not a substitute for the read-modify-commit conflict loop in Best Practice #2: retry logic handles transient failures (network blips, 503s), conflict handling handles legitimate concurrent writes.


TypeScript Types

typescript
import type {
  ShardType,      // 'additive' | 'decision'
  CrdtType,       // 'map' | 'array' | 'text'
  OwnerPolicy,    // 'single-writer' | 'multi-writer'
  ShardValue,     // { value: T, version: number }
  CommitResult,   // { version: number, auditEntryId: string }
  RetryConfig,
  WSConnectionState, // 'connecting' | 'connected' | 'disconnected' | 'error'
} from '@zlock/sdk-ts';

Testing

Because ZeroLockClientConfig accepts a custom fetch, you can unit-test code that uses the client without a real gateway running:

typescript
import { ZeroLockClient } from '@zlock/sdk-ts';
 
const mockFetch = async (url: string, init?: RequestInit) => {
  if (url.includes('/shards/my-shard') && (!init || init.method === undefined)) {
    return new Response(JSON.stringify({ value: { count: 5 }, version: 2 }), { status: 200 });
  }
  if (url.includes('/commit')) {
    return new Response(JSON.stringify({ version: 3, audit_entry_id: 'ae_test' }), { status: 200 });
  }
  return new Response('not found', { status: 404 });
};
 
const client = new ZeroLockClient({ gateway: 'http://localhost:8080', fetch: mockFetch as typeof fetch });

Note the mocked response bodies use the gateway's real wire field names (audit_entry_id, snake_case) — the SDK translates these into camelCase (auditEntryId) on your behalf; a mock that returns camelCase fields directly will silently produce undefined values instead of failing loudly, since the parsing code just reads whatever key is there.

For integration tests that exercise real conflict behavior, prefer running against an actual local gateway (pnpm dev in packages/gateway) rather than mocking 409 responses by hand — the exact conflict-response shape is easy to drift from what the gateway actually returns, and packages/harness already has scenario scaffolding (ZL-001, ZL-002) you can adapt for this rather than writing it from scratch.

Common Pitfalls

  • Forgetting to re-read after a StaleCommitError. Catching the error isn't enough — you must fetch the shard's current value again before recomputing your update, or you'll immediately hit the same conflict.
  • Treating 409 as retryable via retryableStatuses. It deliberately isn't in the default list; adding it yourself will cause the SDK to blindly resend an identical stale commit instead of recomputing against current state.
  • Subscribing before connecting. ws.subscribe(...) calls made before await ws.connect() resolves will not register — always await the connection first.
  • Assuming CachedZeroLockClient caches writes, or invalidates automatically without wsUrl. It only ever caches reads, and without wsUrl + autoConnectWs/connectWs(), nothing tells it another agent changed a cached shard.
  • Passing a ZeroLockWSClient to a task-claiming helper. claimTask and friends take the plain ZeroLockClient; there's no WebSocket involvement in the task-claim protocol at all, despite "write-wait-reread" sounding like it might need a push channel.
  • Calling commitShard on an additive array shard to append. Use appendItem — commitShard against an array-typed additive shard returns 400 UNSUPPORTED_DIFF.
  • Relying on releaseTask/completeTask/addTask to retry on conflict. They don't, today — see the parity note in Task Claiming above.

Best Practices

1. Use Type Parameters

typescript
interface MyData {
  count: number;
  items: string[];
}
 
const shard = await client.getShard<MyData>('my-shard');
// shard.value is typed as MyData

2. Handle Conflicts Gracefully

typescript
async function updateWithRetry<T>(
  shardId: string,
  updateFn: (current: T) => T,
  maxAttempts = 3
): Promise<void> {
  for (let i = 0; i < maxAttempts; i++) {
    const shard = await client.getShard<T>(shardId);
    const newValue = updateFn(shard.value);
 
    try {
      await client.commitShard(shardId, newValue, shard.version);
      return;
    } catch (err) {
      if (err instanceof StaleCommitError && i < maxAttempts - 1) {
        continue; // Retry
      }
      throw err;
    }
  }
}

3. Use Read Sets for Dependencies

typescript
// Read dependent shards
const config = await client.getShard('config');
const state = await client.getShard('state');
 
// Commit with read set to detect staleness
await client.commitShard(
  'result',
  computeResult(config.value, state.value),
  resultVersion,
  ['config', 'state'] // Read set
);

4. Implement Exponential Backoff

typescript
const client = new ZeroLockClient({
  gateway: 'http://localhost:8080',
  retry: {
    maxRetries: 5,
    initialDelayMs: 100,
    maxDelayMs: 5000,
    backoffMultiplier: 2,
    jitter: true, // Prevents thundering herd
  },
});

Frequently Asked Questions

Do I need CachedZeroLockClient, or is ZeroLockClient enough? Start with plain ZeroLockClient. Reach for the cached variant only once you've measured that repeated reads of the same shard are a meaningful share of your traffic — and remember to pass wsUrl/autoConnectWs if you want it to actually invalidate on other agents' writes, not just serve reads until a ttlMs expires.

Can I use this SDK from a browser, not just Node? The HTTP client only depends on fetch, which is available in both environments; the fetch override option exists in part to support environments (older Node, non-standard runtimes) without a global fetch. WebSocket support uses the WebSocket global (Node 22+ and browsers) or the implementation you pass as webSocket (for example the ws package on Node 20).

What's the difference between ShardNotFoundError and a 404 I see in raw HTTP? They're the same failure surfaced two ways — ShardNotFoundError is what the SDK throws after translating the raw 404 response; use the typed error rather than inspecting status codes when going through the SDK.

Should I use additive task queues from TypeScript today? Given the parity gap above, prefer a decision-typed queue (the architecture's own recommendation regardless of language — see Task Claiming Protocol) unless you're prepared to add your own retry loop around releaseTask/completeTask/addTask.