TypeScript SDK reference
The TypeScript SDK (@clickhouse/loom, source in sdk/typescript/src/) is a typed client
over the HTTP API. Its op types are generated from the server's operation
registry. It behaves like the Python SDK: identity binding, an
Idempotency-Key for every idempotent op, pacing on the rate-limit headers, retries, a
consumer tag, and typed errors that carry the server's code.
Install
Node 20 or later (it uses the platform fetch). No runtime dependencies.
npm install @clickhouse/loom
The SDK's version is the server release's
(supported versions). A release not yet on npm
installs from its assets
(installing from a GitHub Release).
For a clone at <clone>, build it with
npm --prefix <clone>/sdk/typescript ci && npm --prefix <clone>/sdk/typescript run build,
then run npm install <clone>/sdk/typescript in your project.
The package ships ESM and CommonJS builds with their own declaration files and
imports no Node built-in when it loads; signing in loads node:fs and the
other built-ins it needs on first use. The framework adapters are their own entry
points: @clickhouse/loom/integrations/langchain and
@clickhouse/loom/integrations/strands.
Credentials
The client picks its server and credential as the Python SDK does (readEnv).
The profile option names a loom login session and its server (Node 20.16
or later, 22.3 or later on Node 22). Otherwise
baseUrl wins, else LOOM_BASE_URL, else http://localhost:7777, and the
credential is tokenProvider or apiKey, then LOOM_API_KEY, then a bot's
LOOM_CLIENT_ID with its key or workload token (MachineLogin), then the
session login() / loom login stored in ~/.loom/credentials for that
server (profile LOOM_PROFILE, default default; it refreshes itself), and
finally the first line of ~/.loom/token, the file loom mint-token --save
writes. The last two are read in Node only, on the first request. A stored
session is used only for the server it signed in to.
login(server?, { profile }) signs in with the OAuth device flow and stores
the session in the file the Python SDK and the CLI share; logout(profile?)
revokes it and whoami(profile?) asks the server who it says you are. A
refresh holds the lock directory ~/.loom/.credentials.lock.d, which the
Python SDK takes too. A process takes it by renaming a private directory that
holds an owner file (its pid and host) onto that path, and touches it every
10 s by its own clock while it holds it. A waiter breaks a lock untouched for
60 s, or one untouched for 20 s whose holder on this machine is no longer
running; a Python waiter on Windows does not check the pid and waits the 60 s.
Breaking a lock and releasing one both rename it away under the claim
directory ~/.loom/.credentials.lock.d.breaking, so a break never renames a
lock taken after a release.
A blank or whitespace key counts as no key, so an exported-but-empty
variable does not become Authorization: Bearer. The key is held in an
ECMAScript private field: JSON.stringify shows apiKey: "…1234" and
util.inspect shows no key at all.
Quick example
import { Datapack } from "@clickhouse/loom";
const pack = new Datapack("acme", "support", {
baseUrl: process.env.LOOM_BASE_URL!,
apiKey: process.env.LOOM_API_KEY,
consumer: "support-bot",
});
const bot = pack.agent("assistant");
await bot.remember("deploy_window", "Deploys run Tue and Thu only; never Friday.");
const hits = await bot.recall("when can we deploy?", { top_k: 5 });
const answer = await bot.ask("When is the deploy window?");
if (!answer.abstain) console.log(answer.answer, answer.memory_keys);
ask synthesizes an answer only with a real LLM provider configured on the
server; on the stock stack's fake providers it abstains or returns a stub.
Clients
LoomClient(options)is the whole surface.client.call("memory.search", input)is typed end to end through the generatedOpMap.client.memory.search(input)is the same call through a generated group accessor; multi-word verbs are camel-cased (memory.bulk_set→client.memory.bulkSet).callWithResponse(name, input)returns the body withrequestId,schemaVersion,serverTimingMs,rateLimit,attemptsand the server'shint/hintCode.callRaw(name, body)calls an op the generated table does not know (a newer server): untyped, and with noIdempotency-Keyunless you passidempotencyKey.capabilities()pairsops.capabilitieswith the token's own claims (decoded, not verified), cached 60 s;spec(name)reads one op's generated spec.
Agent(org, namespace, agent, options | client)fills the identity(org, namespace, agent)on every request, withuserId,sessionIdandrunIdper call. Its verbs:remember,recall,ask,ingest,outcome,contextPack,canCall,mentor,guide,session(sessionId)andtools(scope). An agent named"","-"or"*"becomessdk-ts:<script>with a warning.Datapack(org, namespace, options | client).agent(name)spawns agents that share one client.
Options
LoomClientOptions: baseUrl, apiKey, timeoutMs, retry,
ratelimitMaxWaitS (15 s by default; 0 disables 429 waiting), consumer,
onBehalfOf (sent as X-Loom-On-Behalf-Of; the server honours it only when
the token's subject is listed in LOOM_TRUSTED_CALLERS), headers, logger, logLevel, and injectable
fetch, sleep and now for tests. Per call (CallOptions): timeoutMs,
idempotencyKey, requestId, signal and retry.
Typed operations
src/generated/ops.ts is produced by python scripts/codegen/sdk_ts_gen.py
from server.operations.registry.ALL_OPERATIONS: one interface per Pydantic input and
output model, shared definitions hoisted once. OpMap binds every op name to
its input and output; OPS is the table of specs (group, method, required
role, idempotency, maturity, latency budget); GENERATED_OP_COUNT is its size.
Dates ride the wire as ISO-8601 strings; unconstrained fields are unknown. CI
runs the generator with --check, so a server change that alters a request or
response shape cannot merge without regenerating the types.
import { LoomClient } from "@clickhouse/loom";
const loom = new LoomClient({ baseUrl, apiKey });
const out = await loom.call("memory.search", { org, namespace, query: "deploy window", top_k: 10 });
const same = await loom.memory.search({ org, namespace, query: "deploy window" });
Files
loom.knowledge also sends a file for the server's extractor to parse
(ingesting files); the SDK parses nothing.
upload(scope, data, filename)posts the bytes (Uint8Array,ArrayBufferor aBlobsuch as a browserFile) to/v1/knowledge.uploadwith their sha256 (none where Web Crypto is missing, as on a browser page served over plain http). It is paced on a 429 as an op is, retried after a connection failure, and on an answer only when it proves the server never ran it (a retry after a connection that dropped once the file was stored leaves a second upload, which expires unused).waitForIngest(scope, ingestionId)pollsknowledge.ingest_statusuntil the job ends: aTimeoutErroraftertimeoutMs(600 s by default), at once for a job not found, and an abort throughsignalstops it. It polls on real timers, not aTransport's injectedsleep.ingestFile(...)does both aroundknowledge.ingest, titling the document by the file name less its extension unlesstitlesays otherwise.waitTimeoutMsbounds the wait;callholds the upload's and the ingest's own request options (timeoutMsfor a large file over a slow link), andcall.signalalso stops the wait.
const status = await loom.knowledge.ingestFile({
org,
namespace,
data: new TextEncoder().encode("# Runbook"),
filename: "runbook.md",
source_uri: "repo://runbook.md",
});
// status.state: ready | partial | failed | cancelled
The assistant
loom.assistant.stream({ namespace, question, history, signal, readTimeoutMs }) asks the
Loom assistant through POST /v1/assistant/chat/completions as the client's token and
yields { type: "text", text } for each piece of the answer, then one { type: "answer",
answer } with its runId, usage and the citations the server checked ({ kind, text,
verified, reason, ref }), and its support (AssistantSupport:
support; a value the SDK does not know, or none
from an older server, reads as "unchecked"), and its steps: what it read, one { op,
text, ok } per tool call (steps; [] from an older
server, and when the server could not read the run (a run that made no call has none
either)), with stepsCut when the run held more than 200 ledger rows, so steps lists
its first ones. The live loom.step events and loom.text_done are skipped. A refusal
throws its typed error before any text; an error event after the answer began throws the
same way. An answer that began is never resent, nor one refused with a 429; a 401 with a
token provider, which comes before the answer starts, is sent once more with a fresh
token. readTimeoutMs (300,000) bounds the request and then each read, as an answer is
silent while a tool call runs: past it the stream throws TimeoutError. An abort through
signal closes the answer on the server too. loom.assistant.ask is the op: one whole
answer.
Probe before you rely
import { probe } from "@clickhouse/loom";
const p = await probe(loom, {
ops: ["memory.ask", "context.pack"],
fields: { "memory.search": ["exclude_keys"] },
});
if (!p.ok) console.warn(p.missingOps, p.missingFields);
p.unknownToSdk names ops the server has that this SDK version does not.
caps.canCall(op, namespace) is three-valued: true or false when both the
op's role and the token's role are known, undefined when the SDK cannot tell,
in which case make the call and let the server decide.
Sessions and tools
bot.session(id, { userId, flushEvery }) binds one (user, session) scope:
resume({ query }) returns context.pack as system plus the transcript tail
as messages; append(...turns) writes conversation.append now and runs
memory.set_from_messages every flushEvery turns; offload(text, { summary })
parks a large tool result (context.offload) and fetch(blobKey, { pattern })
reads it back; note() appends to working memory; close() flushes. If the
namespace has conversation logging off, the session warns once, sets
transcriptStored = false and keeps extracting.
bot.tools(scope) gives a model six Loom verbs (loom_remember,
loom_recall, loom_ask, loom_offload, loom_fetch, loom_note) as
definitions("openai" | "anthropic" | "generic"), and
one execute(name, args) dispatcher that returns the string the model reads,
validates required arguments, and truncates past maxResultChars. The schemas
match the Python SDK's.
const session = bot.session("thread-42", { userId: "u1", flushEvery: 8 });
const { system, messages } = await session.resume({ query: "resume the billing migration" });
await session.append({ role: "user", content: userText }, { role: "assistant", content: reply });
const blob = await session.offload(buildLog, { summary: "pytest run 812" });
await session.note(blob.pointer);
await session.close();
Pagination
paginate(fetchPage) walks a cursor-paginated op until next_cursor stops;
collect(gen, limit) gathers the items:
import { collect, paginate } from "@clickhouse/loom";
const recentAudit = await collect(
paginate((cursor) => loom.audit.list({ org, namespace, cursor })),
50,
);
LangChain.js adapter
LoomKnowledgeRetriever and LoomMemoryRetriever from
@clickhouse/loom/integrations/langchain extend BaseRetriever from
@langchain/core, an optional peer dependency: install it next to the SDK. They
search knowledge.search and memory.search and return one Document per chunk
or memory. See Frameworks.
Strands adapter
LoomMemoryStore from @clickhouse/loom/integrations/strands implements the
Strands Agents memory-store shape structurally (no dependency on the Strands
package). A write is one memory.set keyed strands/<sha256 prefix> of the
content, so re-adding a fact is idempotent and both SDKs write the same key; a
search returns nothing when Loom abstains, and leaves out memories below the
floor, unless honourAbstain: false. See
Frameworks.
Errors
Every failure is a LoomError with status, code (the server's detail.code),
message, requestId, detail and op, plus category (an ErrorCategory) and
retryable from the server's detail
(the error envelope). For an error whose body
has neither, or one the SDK raised itself, the SDK's own class answers (detail stays what
the server sent):
- an op request whose connection the server never saw (code
NETWORK) isdependencyand retryable. The fetch must name a connect error:ECONNREFUSED,ENOTFOUND,EAI_AGAIN,EHOSTUNREACH,ENETUNREACH,UND_ERR_CONNECT_TIMEOUT, orETIMEDOUTwhile connecting. The SDK reads the code on the error, its causes and each error of anAggregateError, which is where Node's fetch and node-fetch put it. Any other op request with no response has the same code and is resent as a timeout is (retryTimeouts): a socket dropped after the request went out, a browser's bareTypeError, and fetch errors of runtimes that name no such code (Bun, Deno, Workers); - a call that may have run (no response other than a failed connection, a response that
was cut off, or a
TimeoutErrorfor a call that got no answer in time) isdependencyand retryable only on an idempotent op; - a 2xx whose body is not JSON (
INVALID_RESPONSE) isinternal; - a
baseUrlfetch cannot send to (not an http(s) URL, or with credentials in it) is refused when the transport is built, with aninputLoomError, not retryable; - an error raised before sending is
dependencyand retryable when a service the SDK needed first was down (an identity provider, a secret store),authwhen the caller is not signed in, elseinput. A sign-in the server or identity provider could not answer is retryable,capacityon a 429 anddependencyotherwise (away, or a refused or cut-off connection), and the transport asks for the token again underretryConnectionErrors; - a
TimeoutErrorfrom waiting on an ingest (waitForIngest) iscapacityand retryable, since waiting again is safe; one for a token that did not arrive isdependencyand retryable, since the call was never sent; - a call the caller aborted is
inputand not retryable: the caller's choice, after which the call may have run; - a code the SDK never retries is
configuration(a 503 an operator must fix, as the errors reference classes it),internal(MODEL_CALL_UNATTRIBUTED) ordependency(PUBLICATION_PENDING: the write is committed and its rows follow), not retryable; - a 5xx on an op that is not idempotent is not retryable, since the call may have written,
unless its code proves the handler never ran (
AUTH_UNAVAILABLE). This is stricter than the server, which also keeps a refusal it makes before dispatch retryable (CONFIG_DRIFT,MODEL_CHANGE_IN_PROGRESS, anOVERLOADEDfrom the auth gate): a body without the fields does not say where it came from; - any other code is classed by its HTTP status alone, so one the server lists with its own class (the error codes say which) can read differently there.
The subclass follows the HTTP status (errorFromResponse):
| Class | When |
|---|---|
ValidationError |
400, 422 |
AuthError |
401, 403 |
NotFound |
404 |
IdempotentConflict |
409 with code IDEMPOTENT_CONFLICT; a subclass of PreconditionFailed |
WrongCell |
409 with code WRONG_CELL: the org lives in another cell and nothing was written; org and cell (or "") name them, and the router's GET /cells/lookup gives the cell's host; a subclass of PreconditionFailed |
PreconditionFailed |
any other 409 (including PINNED_CONFLICT: check code) but OVERRIDE_NOT_STORED (a LoomError), 412 |
RateLimited |
429 past the pacing budget; retryAfterS is the server's hint |
IdempotentInProgress |
503 with code IDEMPOTENT_IN_PROGRESS once the retries are spent: the first request with this Idempotency-Key still runs; a subclass of BackendUnavailable with retryAfterS |
BackendUnavailable |
5xx after the retries, or no complete response: the message reads <op>: cannot reach <baseUrl>: … (or no response from <baseUrl>: …, or response from <baseUrl> cut off: …) and code is NETWORK_ERROR_CODE |
TimeoutError |
the per-call timeout elapsed |
LoomError |
any other status; a 2xx body that is not JSON has code INVALID_RESPONSE_CODE; a call whose signal aborted is a plain LoomError |
Because the class follows the status, match on e.code to tell codes that
share one (PRECONDITION_FAILED from PINNED_CONFLICT, OVERLOADED from
SCHEMA_PENDING, MODEL_CALL_DEFERRED from BACKEND_UNAVAILABLE). A model call the
inference gateway asked to wait is a BackendUnavailable with code
MODEL_CALL_DEFERRED and no retryAfterS: the wait is in detail.retry_after_s and the
Retry-After header. What each code means is in the errors reference.
import { NotFound, RateLimited } from "@clickhouse/loom";
try {
await loom.memory.get({ org, namespace, agent, memory_key: "k" });
} catch (e) {
if (e instanceof RateLimited) schedule(e.retryAfterS);
else if (!(e instanceof NotFound)) throw e;
}
Retries, pacing and logging
The transport retries connection failures with exponential backoff (3
attempts, 250 ms base), and 5xx for registry-idempotent ops; any other op only
on a 408 or a 503 AUTH_UNAVAILABLE, which the server answers before running
it. An answer the server classes configuration is never resent, since only
an operator's fix ends it. A 429 is retried after Retry-After (seconds or an
HTTP date, honoured up to 60 s) for up to ratelimitMaxWaitS per call, and
the X-RateLimit-* headers are read so a client at the floor waits for the
refill instead of taking a 429. A per-call timeout is retried only for
idempotent ops that are not read-only (retryTimeouts: "idempotent"), since
the server may still be processing it and deduplicates only their
Idempotency-Key.
retry field |
Default | Meaning |
|---|---|---|
maxAttempts |
3 | total attempts including the first |
backoffMs, backoffMaxMs, backoffJitter |
250, 5000, 0.5 | exponential backoff between attempts |
statuses |
408 + 5xx | statuses retried (a 429 is paced, not retried) |
retryConnectionErrors |
true | the connection failed, so the server never saw the request |
retryTimeouts |
"idempotent" |
retry a timeout, a cut-off response or a socket dropped after sending: never, only for idempotent non-read-only ops, or always |
respectRetryAfter, maxRetryAfterS |
true, 60 | honour a Retry-After on a retried status (a 503 OVERLOADED sends one) |
totalBackoffBudgetMs |
∞ | total time one call may sleep between attempts |
Pass a logger (debug, info, warn, error; console by default) and a
logLevel (debug, info, warn, error, off; default from
LOOM_LOG_LEVEL, else warn). One line per completed
call is logged at info, retries at warn, the final failure at error, and the
request with redacted headers at debug. Every request carries X-Request-Id (a
requestId that is not 1 to 64 letters, digits, ., _ or - is replaced by a
generated one before sending, with a warning),
X-Loom-SDK (loom-ts/<version>), X-Loom-Runtime and
X-Loom-Retry-Count, plus User-Agent outside a browser (browsers forbid
setting it) and an Idempotency-Key for a registry-idempotent op.
Versioning
The SDK's version is the product release version; the server does not gate on
it. Guard a newer op or field with probe or capabilities() rather than on a
version number. See Client and server versions.
Each release's changes are in its GitHub Release notes.
Examples and the package README
sdk/typescript/README.md is the package's
own guide (every ts block in it type-checks against src/ in
npm run check). Runnable examples are in
sdk/typescript/examples/: agent-memory
(probe, remember, recall, ask with citations, outcome, typed errors) and
session-resume (a session resumed in a new process, a parked tool result,
and Loom's tools; with OPENAI_API_KEY set a model calls them, without it
the script calls them itself).
Related
- Python SDK.
- HTTP API and Operations reference.
- Agent integration.
- Code generation: the SDK generators.