TypeScript SDK (@telha/sdk)¶
One client surface, two wires: RestTransport (fetch + x-api-key) for external consumers, GrpcTransport (signed tenant tokens over @grpc/grpc-js) for in-cluster app-layer traffic. Source: sdk/typescript/.
Install¶
Not published to npm yet. Today the package is consumed from the workspace (the app layer references it as a workspace dependency). From your own project, use a path dependency against a checkout:
cd telha-v2/sdk/typescript && npm install && npm run build
# then in your project:
npm install /path/to/telha-v2/sdk/typescript
ESM only ("type": "module"), TypeScript types shipped from dist/. Runtime dependencies are @grpc/grpc-js and @grpc/proto-loader (only exercised when you construct a GrpcTransport).
Client construction¶
REST (external consumers)¶
import { TelhaClient, RestTransport } from "@telha/sdk";
const client = new TelhaClient(
new RestTransport({
url: "http://127.0.0.1:7625",
apiKey: process.env.TELHA_API_KEY!,
// retries: 2, // idempotent requests only, on 5xx/transport errors
// backoffMs: 100, // jittered exponential base
}),
);
gRPC (in-cluster app layer)¶
GrpcTransport speaks telha.v1.AppLayerService and mints a signed tenant token per call (metadata x-telha-token) from the shared grpc.token_key:
import { TelhaClient, GrpcTransport } from "@telha/sdk";
const client = new TelhaClient(
new GrpcTransport({
address: "127.0.0.1:7626", // or "unix:/path/to.sock" (Unix only)
tokenKeyHex: process.env.TELHA_GRPC_TOKEN_KEY!, // core's grpc.token_key, 64 hex
tenantId: "1111...",
organizationId: "2222...",
// audience: "app" (default), deadlineMs: 30000, retries: 2, backoffMs: 100
}),
);
REST-only surfaces on GrpcTransport (PR-028 reality). The gRPC app-layer surface maps only these REST shapes onto RPCs:
| REST shape | RPC |
|---|---|
POST /v1/query | Query |
POST /v1/snapshot | Snapshot |
POST /v1/compare | Compare |
POST /v1/records | CreateRecords |
PUT /v1/records/:id | UpdateRecord |
POST /v1/relationships | BatchWrite |
Everything else - records.get, records.history, records.delete, schema, relationship delete - throws UnimplementedError with code GRPC_UNMAPPED (status 501) telling you to use RestTransport. There is no silent fallback.
API surface¶
// query / snapshot / compare / schema
const { records, related, nextCursor, stats } = await client.query({
find: "RISK",
where: { severity: { $gte: 7 } }, // operator objects, never bare scalars
expand: [{ type: "OWNED_BY", direction: "out", depth: 1 }],
limit: 50,
});
const asOf = await client.snapshot({ find: "RISK", atValidTime: "2026-06-01T00:00:00Z" });
const delta = await client.compare({
baseline: { validTime: "2026-06-01T00:00:00Z" },
comparison: { validTime: "2026-07-01T00:00:00Z" },
find: "RISK",
});
// delta.added / delta.removed (reason: "tombstone" | "validityLapse") /
// delta.modified (per-property old -> new) / delta.stats / delta.nextCursor
const { schemas } = await client.schema({ label: "RISK", at: 1750000000000000 });
// records
const [created] = await client.records.create({
labels: ["RISK"],
properties: { title: "Supplier delay", severity: 7 },
relationships: [{ type: "OWNED_BY", node: personId }],
});
const current = await client.records.get(created.id);
const page = await client.records.history(created.id, { limit: 50 });
const updated = await client.records.update(created.id, {
labels: ["RISK"],
properties: { title: "Supplier delay", severity: 8 },
}); // full-snapshot update: appends a new version, history preserved
const receipt = await client.records.delete(created.id); // tombstone cascade
// relationships
const { id: edgeId } = await client.relationships.create({
from: created.id, to: personId, type: "OWNED_BY",
});
await client.relationships.delete(edgeId);
query<T>() and snapshot<T>() take a type parameter to type the returned records. Ingest (POST /v1/ingest) has no dedicated wrapper in the TS client today; call it over REST directly (or use the Python SDK, which wraps it).
Query grammar reference¶
The living reference for the query DSL is the fixture corpus at core-engine/tests/fixtures/query_corpus.json: every accepted shape and every rejection with its expected JSON pointer, pinned by core tests. If a query shape is not in the corpus, do not rely on it.
Errors¶
Non-2xx responses map the envelope {code, message, request_id} onto a typed hierarchy (sdk/typescript/src/errors.ts), all extending TelhaError { code, status, requestId }:
| Class | When |
|---|---|
QueryInvalidError | 400 QUERY_INVALID (message carries the JSON pointer) |
ValidationError | other 4xx (BAD_CURSOR, INVALID_INTERVAL, EMPTY_BATCH, ...) |
AuthError | 401 UNAUTHORIZED |
NotFoundError | 404 |
ConflictError | 409 (TOMBSTONED, TYPE_COLLISION, ALREADY_EXISTS) |
UnimplementedError | 501 |
ServerError | 5xx INTERNAL and transport failures (status 0, code TRANSPORT) |
gRPC status codes map onto the same taxonomy (fromGrpcError): UNAUTHENTICATED → AuthError, INVALID_ARGUMENT → QueryInvalidError/ValidationError, NOT_FOUND, ALREADY_EXISTS/ ABORTED → ConflictError, UNIMPLEMENTED, DEADLINE_EXCEEDED/ UNAVAILABLE → ServerError.
Retry semantics¶
- Only requests marked idempotent retry: reads,
query,snapshot,compare. Writes are never retried by the SDK. - REST: retried on 5xx and transport errors only; 4xx is thrown immediately. Default
retries: 2extra attempts with jittered exponential backoff (backoffMs * 2^(attempt-1) * (0.5 + rand)). - gRPC: retried on
UNAVAILABLEandDEADLINE_EXCEEDEDonly, same backoff shape, per-call deadline 30 s by default.
Token helpers¶
The SDK exports the signed-token primitives (byte-compatible with core's grpc/token.rs; TTL 300 s):
import { signTenantToken, signServiceToken,
AUDIENCE_APP, AUDIENCE_MCP, AUDIENCE_CLARIFY } from "@telha/sdk";
const mcpToken = signTenantToken(tokenKeyHex, {
tenantId, organizationId, audience: AUDIENCE_MCP,
});
const pollToken = signServiceToken(tokenKeyHex, {
workerId: "clarify-bot", audience: AUDIENCE_CLARIFY,
});
These are what the clarify-bot and MCP clients use; operators can mint equivalent tokens manually with telha api-key mcp-token|clarify-token.
See also¶
- Quickstart - endpoint-level walk-through
- Python SDK
- Connectors - the ingest
options.sourceMetaenvelope