ZeroLock Python SDK
zerolock-sdk is the Python counterpart to @zlock/sdk-ts — an async-first client (with a synchronous wrapper for non-async codebases) over the same HTTP and WebSocket wire protocol. Like the TypeScript SDK, it holds no correctness logic itself: every guarantee comes from the gateway, and both SDKs validate against the same shared schema so a shard's shape can't drift between the two languages.
Every code sample and signature below was checked directly against packages/sdk-py/zerolock_sdk/*.py — including a couple of places where the Python and TypeScript SDKs have quietly drifted from each other, which are called out explicitly rather than papered over.
Design Principles
- Async-first, sync available.
ZeroLockClientis the primary, async client;ZeroLockSyncClientwraps it for codebases (scripts, Django views, notebooks) that aren't running an event loop. Both expose the same method names and semantics — only the calling convention differs. - Context managers over manual lifecycle. Both clients are designed to be used as context managers (
async with/with), which ensures the underlying HTTP session and any open WebSocket connections are cleaned up even if an exception propagates out of the block. - Dataclasses, not dicts, for structured returns.
ShardValue,CommitResult,Approval, and friends are typed dataclasses, soshard.valueandshard.versionare attribute accesses with type hints, notshard["value"]dictionary lookups.
Installation
pip install --pre zerolock-sdk
# or
poetry add zerolock-sdkQuick Start
import asyncio
from zerolock_sdk import ZeroLockClient, CreateShardOptions
async def main():
async with ZeroLockClient(gateway="http://localhost:8080") as client:
# Create a shard
await client.create_shard("counter", CreateShardOptions(
type="decision",
initial_value={"count": 0}
))
# Read and update
shard = await client.get_shard("counter")
await client.commit_shard(
"counter",
{"count": shard.value["count"] + 1},
shard.version
)
asyncio.run(main())Client Configuration
from zerolock_sdk import ZeroLockClient
# Async client (recommended)
client = ZeroLockClient(
gateway="http://localhost:8080",
agent_id="my-agent", # Auto-generated as "agent-<8 hex chars>" if not provided
timeout=30.0, # Seconds
api_key="zl_...", # Sent as x-api-key (needed when the gateway uses auth)
run_id="run-001", # Groups this agent's operations into one run in traces
trace_id="trace-abc", # Links gateway events to a span in your own tracing system
metadata={"step": "plan"}, # Attached to every audit event as client_metadata
)
# Use as context manager
async with client:
shard = await client.get_shard("my-shard")ZeroLockClient.__init__ also accepts an http_client: httpx.AsyncClient | None parameter, mainly for injecting a pre-configured or test httpx.AsyncClient — most code never needs to pass this.
Authentication and Trace Context
api_key is sent as the x-api-key header on every request (HTTP and the WebSocket handshake), never in the URL. run_id, trace_id and metadata are stored in the metadata of every audit entry the gateway writes while handling the request (run_id, trace_id, client_metadata), including conflicts. They are optional and never affect correctness: invalid or oversized values are dropped, not rejected.
client.set_context(run_id="run-002", trace_id="trace-def") # new run, same key
# Share the context with the other clients
approvals = ApprovalClient(gateway="http://localhost:8080", agent_id="planner-1", context=client.context)
ws = ZeroLockWSClient(WSClientConfig(ws_url="ws://localhost:8080/ws", api_key="zl_...", run_id="run-002"))Sync Client
from zerolock_sdk import ZeroLockSyncClient
with ZeroLockSyncClient(gateway="http://localhost:8080") as client:
shard = client.get_shard("my-shard")
client.commit_shard("my-shard", {"key": "value"}, shard.version)Reach for ZeroLockSyncClient when you're integrating into code that isn't async def all the way up — a Django view, a Jupyter notebook cell, a simple CLI script. It's a thin synchronous wrapper around the same underlying async client (it runs its own private event loop internally via __enter__/__exit__), so it carries the same request semantics and error types, just without needing await. It currently exposes get_shard, commit_shard, append_item, create_shard, and health_check — the task-claiming helpers and ApprovalClient are async-only today; wrap them in asyncio.run(...) yourself if you need them from synchronous code.
Shard Operations
Create Shard
from zerolock_sdk import CreateShardOptions
# Decision shard (OCC)
await client.create_shard("my-shard", CreateShardOptions(
type="decision",
initial_value={"key": "value"},
reversible=True,
owner_policy="multi-writer",
value_schema={
"type": "object",
"properties": {
"key": {"type": "string"}
}
}
))
# Additive shard (CRDT)
await client.create_shard("task-queue", CreateShardOptions(
type="additive",
crdt_type="map" # "map" or "array" for commits today; "text" can be created but not committed to
))create_shard returns a plain int — the initial version — not a dataclass; unlike the TypeScript SDK's createShard, which returns { version: number }.
Read Shard
shard = await client.get_shard("my-shard")
print(f"Value: {shard.value}")
print(f"Version: {shard.version}")Commit Update (Decision Shards)
from zerolock_sdk import StaleCommitError
try:
result = await client.commit_shard(
"my-shard",
{"key": "new-value"},
current_version,
read_set=["dependency-shard"] # Optional
)
print(f"New version: {result.version}")
except StaleCommitError as e:
print(f"Conflict! Current version: {e.current_version}")commit_shard is for decision shards and additive map shards only — see below for additive array shards, which use a different method, not commit_shard with a list.
Append to an Additive Shard
# Additive ARRAY shards: use append_item, not commit_shard
await client.append_item("findings-log", {"question": "q-1", "by": "researcher-3"})append_item is a true CRDT push, not a read-modify-write: it never raises StaleCommitError and has no expected_version parameter at all. Every concurrent caller's item survives regardless of who else is appending to the same shard at the same time — verified live at up to 80 concurrent appenders with zero loss. Calling it against a shard that isn't an additive array shard raises GatewayError with a 400 UNSUPPORTED_DIFF status. ZeroLockSyncClient.append_item is also available for non-async code.
For additive map shards, keep using commit_shard — the dict you pass is applied as a grow/update-only patch (absent keys are left alone, a key set to None/null is deleted).
WebSocket Client
Connection
from zerolock_sdk import ZeroLockWSClient, WSClientConfig
ws = ZeroLockWSClient(WSClientConfig(
ws_url="ws://localhost:8080/ws",
agent_id="my-agent",
auto_reconnect=True, # default: True
reconnect_delay=1.0 # default: 1.0 seconds
))
await ws.connect()As with the TypeScript client, auto_reconnect re-establishes the socket connection automatically, but does not re-subscribe you to previously watched shards — call ws.subscribe(...) again after reconnecting, ideally from an on_invalidate/connection-state callback so it happens without manual intervention.
Subscriptions
# Subscribe to shard updates
ws.subscribe("my-shard")
# Handle invalidation (Layer 2)
async def on_invalidate(shard_id: str, version: int, changed_by: str):
print(f"Shard {shard_id} updated to v{version} by {changed_by}")
ws.on_invalidate = on_invalidate
# Handle conflicts (Layer 3)
async def on_conflict(
shard_id: str,
current_version: int,
winning: Any,
rejected: Any,
diff: Any | None
):
print(f"Conflict on {shard_id}")
print(f"Winning: {winning}")
print(f"Rejected: {rejected}")
ws.on_conflict = on_conflict
# Unsubscribe
ws.unsubscribe("my-shard")CRDT Sync
# Request sync for additive shard
response = await ws.request_sync("crdt-shard", state_vector)
# Apply update using Yjs library (e.g., y-py)
# update = base64.b64decode(response.update)The Python ecosystem's Yjs bindings (y-py) are a separate dependency from zerolock-sdk itself — install it directly if your agent needs to apply and merge CRDT updates locally rather than only reading the gateway's already-merged state via get_shard.
Cached Client (Layer 2)
from zerolock_sdk import CachedZeroLockClient, CachedClientConfig, CacheConfig
client = CachedZeroLockClient(CachedClientConfig(
gateway="http://localhost:8080",
agent_id="my-agent",
ws_url="ws://localhost:8080/ws", # required for automatic invalidation
auto_connect_ws=True,
cache_config=CacheConfig(
max_entries=1000,
ttl_seconds=60, # NOTE: seconds, not milliseconds - see below
)
))
# Automatically uses cache for reads
shard = await client.get_shard("my-shard")
# Cache stats
stats = client.get_cache_stats()
print(f"Hit rate: {stats.hit_rate:.2%}")Two details worth being precise about, since both are easy to get wrong porting code from the TypeScript SDK:
- The config field is
cache_config, notcache—CachedClientConfig(cache=...)will silently be accepted by nothing (it's not a real field) rather than erroring, so double-check you wrotecache_config. CacheConfig's TTL field isttl_seconds(afloat, in seconds), notttl_ms. The TypeScript SDK's equivalent field isttlMs, in milliseconds — the two SDKs use different units for what is conceptually the same setting.ttl_seconds=60in Python is the equivalent ofttlMs: 60000in TypeScript, notttlMs: 60.
ws_url and auto_connect_ws are not optional extras — without them, this client's cache is never invalidated by other agents' writes, exactly as on the TypeScript side. commit_shard always goes to the gateway with a real version check, so a stale cached read can at worst cause an extra StaleCommitError round-trip — it can never cause an unsafe write to actually apply. append_item is not exposed on CachedZeroLockClient — use a separate plain ZeroLockClient for additive appends.
Listing, Deleting and Telemetry
page = await client.list_shards(limit=50) # page.shards, page.next_cursor
more = await client.list_shards(after=page.next_cursor)
await client.delete_shard("old-shard") # raises ShardNotFoundError if missing
# Observe every HTTP request (hooks that raise are ignored)
client = ZeroLockClient(
gateway="http://localhost:8080",
on_request=lambda e: print(e["method"], e["url"]),
on_response=lambda e: print(e["status"], e["duration_ms"], e["error"]),
)
# Report your own events into the gateway trace (best-effort, returns False on failure)
await client.report_event("claim_won", "task-queue", {"task_id": "task-1"})claim_task(..., report_events=True) reports claim_attempt and claim_won/claim_lost for you. The sync client has the same methods.
For tasks on an additive map, pass claim_log="my-claims" (an existing additive array shard) to claim_task. 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
from zerolock_sdk import ZeroLockClient, claim_task, complete_task, add_task
async with ZeroLockClient(gateway="http://localhost:8080", agent_id="my-agent") as client:
# Add task to queue - takes the plain HTTP client, not a WebSocket client
await add_task(client, "task-queue", "task-1", payload={"type": "process-data"})
# Claim task (write-wait-reread protocol)
result = await claim_task(client, "task-queue", "task-1", "my-agent")
if result.claimed:
# Process task...
# Mark complete - agent_id is required here too
await complete_task(client, "task-queue", "task-1", "my-agent")
else:
print(f"Task already claimed by: {result.owner}")All five helpers — claim_task, release_task, complete_task, add_task, get_pending_tasks — take a plain ZeroLockClient, not a WebSocket client, as their first argument. claim_task returns a TaskClaimResult dataclass with claimed: bool, owner: str, and version: int — there is no .success or .claimed_by attribute; the fields are .claimed and .owner. complete_task/release_task return a plain bool; add_task returns the new version as an int.
@dataclass
class TaskClaimResult:
claimed: bool
owner: str # the agent that ends up owning the task, whether that's you or someone else
version: intThe additive parameter, and why it exists
# Recommended default: decision-typed queue, additive not needed
result = await claim_task(client, "task-queue", "task-1", "my-agent")
# If your queue shard was created with type="additive" instead:
result = await claim_task(client, "task-queue", "task-1", "my-agent", additive=True)
await complete_task(client, "task-queue", "task-1", "my-agent", additive=True)claim_task, release_task, complete_task, and add_task all accept an additive: bool = False parameter. When True, the function sends only the single task key it actually changed as the commit payload, instead of this agent's whole locally-known tasks dict. This exists because of a real, reproduced bug: under genuine concurrency, a "read the whole dict, change one key, write the whole dict back" pattern can silently roll back a different key that another agent concurrently changed, because the agent doing the write is working from an already-stale full copy of every other task too. Sending only the one key you actually changed makes that impossible regardless of timing. This only matters if your queue shard is type="additive" — a decision-typed queue doesn't have this failure mode at all, because OCC's version check on the whole commit already rejects any commit built on stale data, full stop. See Architecture — Task Claiming Protocol for why a decision-typed queue is the recommended default regardless.
Retry behavior, and a bug this fixed
claim_task has always retried on StaleCommitError (max_retries=3 default, retry_delay=0.05 seconds, linear backoff). release_task, complete_task, and add_task now do too, with a much higher default (max_retries=25, retry_delay=0.03) — this is a recent fix, not a long-standing feature. Earlier versions of these three functions had no retry loop at all: the first StaleCommitError on a shared decision-shard queue (where every task shares one version counter, so any other agent's concurrent claim or completion can invalidate your read) would immediately return False / propagate, and calling code frequently wasn't even checking that return value. Confirmed impact before the fix: 54 of 100 task completions silently lost in a 50-concurrent-agent run. If you're on an older zerolock-sdk version, upgrading is the fix — there's no application-level workaround needed once these functions retry correctly. add_task still raises StaleCommitError (rather than returning a sentinel) if every retry is exhausted, since there's no sensible boolean "did it work" fallback for a function whose job is to add something new.
Parity note: the TypeScript SDK's equivalent helpers do not yet have either the additive parameter or the retry-on-conflict behavior for releaseTask/completeTask/addTask — see TypeScript SDK — Task Claiming if you're working across both languages in the same system.
Approvals (Layer 4)
from zerolock_sdk import ApprovalClient
async with ApprovalClient(
gateway="http://localhost:8080",
agent_id="my-agent"
) as approvals:
# Request approval for irreversible operation
approval = await approvals.request_approval(
shard_id="payment-shard",
proposed_value={"amount": 1000},
expected_version=2,
description="Process payment",
ttl_seconds=3600, # optional, server default is 1 hour
)
print(f"Approval ID: {approval.id}")
print(f"Status: {approval.status}") # "pending"
# Wait for approval (polling) - note: seconds, not milliseconds
result = await approvals.wait_for_approval(
approval.id,
timeout_seconds=300, # 5 minutes; None for no timeout
poll_interval_seconds=5, # default: 1.0
)
if result.status == "approved":
print(f"Approved! Committed version: {result.version}")request_approval's parameters are shard_id, proposed_value, expected_version, description (optional), and ttl_seconds (optional) — there is no operation or value/reason parameter; the operation is always implicitly "commit proposed_value." wait_for_approval's timing parameters are poll_interval_seconds and timeout_seconds — both in seconds, unlike the method's name-alikes elsewhere suggesting milliseconds; passing 300000 here (a common mistake if you're used to millisecond APIs) waits roughly 83 hours, not 300 seconds.
wait_for_approval raises TimeoutError if timeout_seconds is exceeded, but returns normally (does not raise) for all three terminal states — approved, rejected, expired — check result.status explicitly rather than wrapping the call in a broad try/except expecting rejection to raise.
status == "approved" does not always mean your commit applied. If the target shard changed between your request_approval 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 result.version will be None. Check result.version is not None if you need to know whether the operation actually took effect, not just whether the approval itself was granted.
Error Handling
from zerolock_sdk import (
ZeroLockError,
StaleCommitError,
ShardNotFoundError,
ShardExistsError,
GatewayError,
)
try:
await client.commit_shard("shard", value, version)
except StaleCommitError as e:
# OCC conflict - retry with new version
print(f"Expected: {e.expected_version}")
print(f"Current: {e.current_version}")
except ShardNotFoundError as e:
print(f"Shard {e.shard_id} not found")
except GatewayError as e:
print(f"Gateway error: {e.status_code}")Every exported exception subclasses ZeroLockError, so except ZeroLockError: is a reliable way to catch "a known gateway failure mode" as a category, separate from unrelated exceptions (a bug elsewhere in your async task, a JSON decoding error) that shouldn't be silently swallowed the same way. A 403 APPROVAL_REQUIRED response surfaces as a plain GatewayError with status_code == 403, not a dedicated exception class — check e.status_code for that case.
Retry Logic
from zerolock_sdk import with_retry, RetryConfig
# One-off retry
result = await with_retry(
lambda: client.get_shard("my-shard"),
RetryConfig(max_retries=5)
)
print(f"Success after {result.attempts} attempts")
# Sync version
from zerolock_sdk import with_retry_sync
result = with_retry_sync(
lambda: sync_client.get_shard("my-shard"),
RetryConfig(max_retries=3)
)Retry Configuration
from zerolock_sdk import RetryConfig, DEFAULT_RETRY_CONFIG
config = RetryConfig(
max_retries=5,
initial_delay_ms=100,
max_delay_ms=5000,
backoff_multiplier=2.0,
retryable_statuses=[502, 503, 504, 429],
jitter=True
)
# Or use defaults
config = DEFAULT_RETRY_CONFIGNote RetryConfig's delay fields are named *_ms and are in milliseconds — unlike CacheConfig.ttl_seconds above, this one does match the TypeScript SDK's units (initialDelayMs, maxDelayMs). The inconsistency is specifically in the cache TTL field, not a general Python-uses-seconds rule — check each config's field name/suffix rather than assuming a language-wide convention.
As on the TypeScript side, 409 is intentionally absent from retryable_statuses — a stale-version conflict means your computed value is out of date, not that the request itself transiently failed, so blind retry would just resend the same stale commit. Use Best Practice #2's read-modify-commit loop for StaleCommitError instead. with_retry/with_retry_sync only wrap whatever function you hand them — they don't automatically wrap get_shard/commit_shard the way the TypeScript client's constructor-level retry option does; in Python, retry is always something you opt into explicitly per call.
Type Hints
from zerolock_sdk import (
ShardValue,
CommitResult,
CreateShardOptions,
WSConnectionState,
Approval,
ApprovalStatus,
)
# ShardValue is a dataclass
@dataclass
class ShardValue:
value: Any
version: int
# Use type hints
def process_shard(shard: ShardValue) -> dict:
return {"value": shard.value, "version": shard.version}Testing
Because ZeroLockClient speaks plain HTTP and WebSocket protocols, the most reliable way to unit-test code that depends on it is to point it at a real local gateway (pnpm dev in packages/gateway) rather than mocking the client — the wire protocol's error shapes (particularly the 409 conflict body) are easy to get subtly wrong by hand. For pure unit tests that don't need real gateway behavior, wrap your own business logic in a small interface and inject a fake implementing just the methods you call, rather than mocking ZeroLockClient itself.
import pytest
from zerolock_sdk import ZeroLockClient, CreateShardOptions
@pytest.fixture
async def client():
async with ZeroLockClient(gateway="http://localhost:8080") as c:
yield c
async def test_commit_increments_version(client):
await client.create_shard("test-counter", CreateShardOptions(type="decision", initial_value={"n": 0}))
shard = await client.get_shard("test-counter")
result = await client.commit_shard("test-counter", {"n": 1}, shard.version)
assert result.version == shard.version + 1Common Pitfalls
- Passing
timeout_ms/milliseconds towait_for_approval. Its parameters aretimeout_secondsandpoll_interval_seconds— both seconds. A value copied from TypeScript'stimeoutMs: 300000will wait roughly 83 hours if pasted in astimeout_seconds=300000. - Writing
cache=CacheConfig(...)instead ofcache_config=CacheConfig(...)onCachedClientConfig— the former is not a real field. - Writing
ttl_msinstead ofttl_secondsonCacheConfig— and forgetting the unit conversion (divide the TypeScriptttlMsvalue by 1000). - Reusing a
ZeroLockClientacross event loops. The client is bound to the event loop it was created in — creating it in oneasyncio.run()call and reusing it in another will fail unpredictably; create a fresh client (or use the context manager) per run. - Forgetting
read_seton multi-shard commits. If a commit's value was derived from more than one shard, omitting the dependent shard IDs fromread_setmeans a change to those shards after you read them won't be caught, even though the shard you're writing to is version-checked correctly. - Mixing sync and async clients on the same agent. Pick one per code path;
ZeroLockSyncClientandZeroLockClientdon't share connection state, so alternating between them for the same logical agent doesn't buy you anything and adds confusion. - Assuming
release_task/complete_task/add_taskalways retried on conflict. They didn't in older versions — see Task Claiming above if you're unsure which behavior your installed version has.
Best Practices
1. Use Context Managers
# Ensures proper cleanup
async with ZeroLockClient(gateway="http://localhost:8080") as client:
shard = await client.get_shard("my-shard")2. Handle Conflicts Gracefully
from typing import Callable, TypeVar
from zerolock_sdk import StaleCommitError
T = TypeVar("T")
async def update_with_retry(
client: ZeroLockClient,
shard_id: str,
update_fn: Callable[[T], T],
max_attempts: int = 3
) -> None:
for _ in range(max_attempts):
shard = await client.get_shard(shard_id)
new_value = update_fn(shard.value)
try:
await client.commit_shard(shard_id, new_value, shard.version)
return
except StaleCommitError:
continue # Retry
raise Exception(f"Failed after {max_attempts} attempts")3. Use Read Sets for Dependencies
# Read dependent shards
config = await client.get_shard("config")
state = await client.get_shard("state")
# Commit with read set to detect staleness
await client.commit_shard(
"result",
compute_result(config.value, state.value),
result_version,
read_set=["config", "state"]
)4. Implement Structured Concurrency
import asyncio
async def process_shards(client: ZeroLockClient, shard_ids: list[str]):
async def process_one(shard_id: str):
shard = await client.get_shard(shard_id)
# Process...
await asyncio.gather(*[process_one(s) for s in shard_ids])5. Use Logging
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("zerolock")
async with ZeroLockClient(gateway="http://localhost:8080") as client:
shard = await client.get_shard("my-shard")
logger.info(f"Read shard: {shard.value} (v{shard.version})")API Reference
ZeroLockClient
| Method | Description |
|---|---|
get_shard(shard_id) | Read shard value and version |
commit_shard(shard_id, value, version, read_set=None) | Commit new value (decision or additive-map shards) |
append_item(shard_id, item) | Push an item onto an additive array shard (true CRDT append) |
create_shard(shard_id, options) | Create new shard — returns the initial version as int |
health_check() | Check gateway health |
ZeroLockWSClient
| Method | Description |
|---|---|
connect() | Connect to WebSocket |
disconnect() | Disconnect |
subscribe(shard_id) | Subscribe to shard updates |
unsubscribe(shard_id) | Unsubscribe |
request_sync(shard_id, state_vector=None) | Request CRDT sync |
send_update(shard_id, update) | Send CRDT update |
ApprovalClient
| Method | Description |
|---|---|
request_approval(shard_id, proposed_value, expected_version, description=None, ttl_seconds=None) | Request approval |
get_approval(approval_id) | Get approval status |
approve(approval_id) | Approve request |
reject(approval_id, reason=None) | Reject request |
wait_for_approval(approval_id, poll_interval_seconds=1.0, timeout_seconds=None) | Poll for resolution (seconds, not ms) |
Frequently Asked Questions
Should new code use ZeroLockClient or ZeroLockSyncClient? Prefer the async client by default — it composes naturally with asyncio.gather for concurrent shard operations (see Best Practice #4) and matches how the gateway itself is built. Reach for the sync wrapper only when integrating into a codebase that genuinely isn't async, and remember it doesn't cover task-claiming or approvals yet.
Is there a difference in correctness guarantees between the Python and TypeScript SDKs? No — both are thin clients over the identical wire protocol and validate against the same shared schema. There are, however, real feature differences today, specifically in the task-claiming helpers (the additive parameter and conflict-retry logic exist in Python, not yet in TypeScript) and in a couple of config field names/units (ttl_seconds vs ttlMs) — both called out above. Watch for these if you're running agents in both languages against the same gateway.
How do I know if y-py (or another Yjs binding) is required for my use case? Only if you need to apply and locally merge CRDT updates for an additive shard yourself (e.g. building a custom collaborative view). If you only ever read an additive shard's already-merged value via get_shard, you don't need it.
