The TypeScript libraries give apps, services, devices, portals, and CLIs a typed way to connect to Trellis and use the APIs their contracts allow.

Use this page as a practical map of the TypeScript surface. For exact signatures, see the API Reference.

Packages

Common packages are:

  • @qlever-llc/trellis: core TypeScript runtime, contract helpers, client connection helpers, generated Trellis-owned SDK re-exports, Result helpers, operations, transfers, state, jobs, and errors.
  • @qlever-llc/trellis/service/deno: service runtime entrypoint for Deno.
  • @qlever-llc/trellis/service/node: service runtime entrypoint for Node.js.
  • @qlever-llc/trellis/telemetry: OpenTelemetry helpers for tracing, propagation, initialization, and Trellis error metrics.
  • @qlever-llc/trellis-test: Deno-first integration test helpers for service repositories that need isolated NATS/JetStream and a real Trellis control-plane process.
  • @qlever-llc/trellis-svelte: Svelte and SvelteKit integration helpers.
  • generated SDK packages under generated/packages/jsr/ and generated/packages/npm/ for contracts in your project.

Generated SDKs include contract metadata, TypeScript schema-derived types, use(...) helpers for consumers, and client.ts facade types such as Client and Service.

Connect as an app or CLI

Apps and CLIs usually connect with TrellisClient.connect(...). The contract controls which surfaces are visible on the returned client.

import { TrellisClient } from "@qlever-llc/trellis";
import appContract from "./contract.ts";

const client = await TrellisClient.connect({
  trellisUrl: "http://localhost:3000",
  contract: appContract,
}).orThrow();

const me = await client.rpc.auth.sessionsMe({}).orThrow();
console.log(me.participantKind);

Use the surface-first generated facade (client.rpc.group.method(...)) for application code instead of stringly typed RPC calls.

const order = await client.rpc.orders.get({ orderId: "ord_123" }).orThrow();

Connect as a service

Services connect through the service runtime. The runtime authenticates the service instance, presents contract evidence, resolves resource bindings, and returns a typed service object.

Treat those resolved bindings as runtime internals. Service code should use the service.kv, service.store, and service.jobs handles returned by TrellisService.connect(...); do not import the core SDK to bootstrap a service, call Trellis.Bindings.Get, construct TrellisService or StoreHandle, or pass binding/resource payloads into Trellis constructors.

import { TrellisService } from "@qlever-llc/trellis/service/deno";
import { ordersService } from "./contracts/orders_service.ts";

const service = await TrellisService.connect({
  trellisUrl: Deno.env.get("TRELLIS_URL")!,
  contract: ordersService,
  name: "orders-service",
  sessionKeySeed: Deno.env.get("ORDERS_SERVICE_SESSION_KEY_SEED")!,
}).orThrow();

await service.wait();

For Node.js, import from @qlever-llc/trellis/service/node and use your normal environment variable loader.

Service telemetry

TrellisService.connect(...) initializes Trellis telemetry by default using the service name. This configures trace IDs on Trellis errors and, when the environment configures OpenTelemetry exporters, installs tracing and metrics runtime support.

const service = await TrellisService.connect({
  trellisUrl: Deno.env.get("TRELLIS_URL")!,
  contract: ordersService,
  name: "orders-service",
  sessionKeySeed: Deno.env.get("ORDERS_SERVICE_SESSION_KEY_SEED")!,
}).orThrow();

Disable automatic telemetry only when your process owns OpenTelemetry setup and will initialize Trellis telemetry explicitly.

import { initTelemetry } from "@qlever-llc/trellis/telemetry";
import { TrellisService } from "@qlever-llc/trellis/service/deno";

initTelemetry("orders-service");

const service = await TrellisService.connect({
  trellisUrl: Deno.env.get("TRELLIS_URL")!,
  contract: ordersService,
  name: "orders-service",
  sessionKeySeed: Deno.env.get("ORDERS_SERVICE_SESSION_KEY_SEED")!,
  telemetry: false,
}).orThrow();

The telemetry subpath also exports trace propagation helpers such as injectTraceContext(...), extractTraceContext(...), getTracer(...), and getActiveSpan(...) for advanced runtime integration. The old @qlever-llc/trellis/tracing subpath is no longer published; use @qlever-llc/trellis/telemetry.

Trellis records runtime error metrics with a trellis.errors OpenTelemetry counter. Metrics are low-cardinality by design: labels describe stable surfaces, directions, operations, phases, and Trellis error types. Do not attach user IDs, session keys, trace IDs, request IDs, payloads, raw NATS subjects, or error messages as metric attributes in application code.

Connect as a device

Devices have their own activation and connection flow. Check activation first, then connect once the deployment has approved the device identity.

import { TrellisDevice } from "@qlever-llc/trellis";
import { checkDeviceActivation } from "@qlever-llc/trellis/device/deno";
import deviceContract from "./contract.ts";

const activation = await checkDeviceActivation({
  trellisUrl: "http://localhost:3000",
  contract: deviceContract,
  rootSecret: await Deno.readFile("./device-root-secret.bin"),
});

if (activation.status === "activation_required") {
  console.log("Open", activation.activationUrl);
  await activation.waitForOnlineApproval();
}

const device = await TrellisDevice.connect({
  trellisUrl: "http://localhost:3000",
  contract: deviceContract,
  rootSecret: await Deno.readFile("./device-root-secret.bin"),
}).orThrow();

Use generated SDKs in contracts

When one participant needs another contract, import that contract’s generated SDK and use its use(...) helper.

import { defineAppContract } from "@qlever-llc/trellis";
import { sdk as orders } from "@my-org/orders-service-sdk";

export default defineAppContract(() => ({
  id: "orders-console@v1",
  displayName: "Orders Console",
  description: "Operator UI for order lookup.",
  uses: {
    required: {
      orders: orders.use({
        rpc: { call: ["Orders.Get"] },
        events: { subscribe: ["Orders.Shipped"] },
        feeds: { subscribe: ["Orders.Live"] },
        operations: { call: ["Orders.Process"] },
      }),
    },
  },
}));

Only selected surfaces appear on the connected client type.

Surface facades

Trellis groups names by the first dot and lower-cases the first word. For example, Orders.Get becomes client.rpc.orders.get(...).

RPC

Use RPC for a typed request and response.

const result = await client.rpc.orders.get({ orderId: "ord_123" });
const order = result.take();

if (order.isErr()) {
  console.warn(order.error.message);
} else {
  console.log(order.value.status);
}

Events

Use events for durable facts that other participants can react to.

await client.event.orders.shipped.listen(async (event) => {
  console.log("shipped", event.orderId);
}, {}, { mode: "ephemeral", replay: "new" }).orThrow();

Service code that needs durable delivery must declare an eventConsumers group in its service contract and pass that logical group name from startup code when listening:

await service.event.orders.shipped.listen(async (event) => {
  await projectShipment(event);
  return Result.ok(undefined);
}, {}, { group: "shipmentProjection" }).orThrow();

Do not pass durableName; Trellis provisions the physical JetStream consumer from the contract and returns the binding during service bootstrap. Use mode: "ephemeral" for live-only listeners that do not need a durable cursor.

Register service listeners during startup, not inside RPC, job, operation, or feed handlers. Handler-injected clients are outbound-only for events: they can publish(...) and prepare(...), but they cannot listen(...).

Services publish events from handler or job code through the scoped client passed to that code:

import { Result } from "@qlever-llc/trellis";

await service.handle.rpc.orders.ship(async ({ input, client }) => {
  await client.event.orders.shipped.publish({
    orderId: input.orderId,
    customerId: input.customerId,
    shippedAt: new Date().toISOString(),
  }).orThrow();

  return Result.ok({ accepted: true });
});

Direct publish(...) is the normal default. It prepares the event body, assigns runtime event metadata, publishes to JetStream, and returns when the publish is acknowledged.

Generated SDK event types represent the contract event body you author. Runtime metadata such as the generated event id and timestamp is available on PreparedTrellisEvent.header after prepare(...) and through the listener context when consuming events.

For Deno-first integration tests that need to assert a live event was published, use @qlever-llc/trellis-test and start runtime.captureEvents(...) before the publish. The capture subscribes through generated contract event surfaces and returns decoded payloads plus metadata, so tests should not hand-build NATS subjects or JSON envelopes:

await using capture = await runtime.captureEvents({
  name: "orders-event-capture",
  contract: ordersContract,
  events: ["Orders.Shipped"],
});

await client.event.orders.shipped.publish({
  orderId: "ord_123",
  customerId: "cust_456",
  shippedAt: new Date().toISOString(),
}).orThrow();

const shipped = await capture.waitFor(
  "Orders.Shipped",
  (record) => record.payload.orderId === "ord_123",
);

console.log(shipped.payload, shipped.context.id, shipped.receivedAt);

Use prepare(...) plus an outbox only when the event must be committed together with service-local database state. The prepared form freezes the subject, encoded body, runtime event metadata, and publish headers so a worker can publish the same event later. PreparedTrellisEvent intentionally has no contract id or contract digest; persisted outbox rows should not be coupled to the publisher’s current deployment metadata.

import {
  OutboxDispatcher,
  SqlOutboxRepository,
} from "@qlever-llc/trellis/service";
import { createDrizzleSqlExecutor } from "@qlever-llc/trellis/service/drizzle";
import { Result } from "@qlever-llc/trellis";

// Create one dispatcher for the service process. Use a non-transactional
// repository here; `outboundClient` is the publisher used by the dispatch
// worker, and transactional handlers still create repositories over `tx`.
const outboxRepository = new SqlOutboxRepository(
  createDrizzleSqlExecutor(db),
  "sqlite",
);
const outboxDispatcher = new OutboxDispatcher(outboxRepository, outboundClient, {
  idleRetryMs: 30_000,
});

await service.handle.rpc.orders.ship(async ({ input, client }) => {
  const prepared = client.event.orders.shipped.prepare({
    orderId: input.orderId,
    customerId: input.customerId,
    shippedAt: new Date().toISOString(),
  }).orThrow();

  await db.transaction(async (tx) => {
    await tx.update(orders).set({ status: "shipped" });

    // Wrap the same transaction executor so the local DB write and outbox row
    // commit or roll back together.
    const executor = createDrizzleSqlExecutor(tx);
    const outbox = new SqlOutboxRepository(executor, "sqlite");
    await outbox.enqueue(prepared);
  });

  // Signal after the transaction commits so rolled-back rows are never
  // dispatched. Signals are debounced and only one drain loop runs at a time.
  outboxDispatcher.notify();

  return Result.ok({ accepted: true });
});

SQL and Drizzle-based services own their schema migrations and transaction boundaries. Trellis provides SQL repository adapters, DDL helpers, and the optional @qlever-llc/trellis/service/drizzle executor helper for caller-owned Drizzle SQLite databases or transactions. Your service should put the outbox/inbox tables into its normal migration flow and use the same database transaction when coupling an outbox row to local state. The dispatcher is process-local coordination; idleRetryMs is optional, but useful as a low-frequency recovery scan for missed signals or rows left behind after a restart.

Use an inbox only for handlers that are not naturally idempotent. If replaying a message would be harmless because the handler writes idempotently by business key, no inbox is needed.

import { SqlInboxRepository } from "@qlever-llc/trellis/service";

await service.event.orders.shipped.listen(async (event, context) => {
  const inbox = new SqlInboxRepository(db, "postgres");
  if (!(await inbox.record(context.id))) return;

  await applyNonIdempotentSideEffect(event);
}, {}, { group: "shipmentProjection" }).orThrow();

NatsKvOutboxRepository and NatsKvInboxRepository are durable queue and dedupe helpers for services that do not have SQL state. They are not transactional with unrelated database side effects, so do not use them to claim that an arbitrary DB write and an event enqueue committed atomically.

Feeds

Use feeds for filtered live views where the caller sends an input and receives a stream of typed events.

const subscription = await client.feed.orders.live({
  customerId: "cust_456",
}).orThrow();

for await (const event of subscription) {
  console.log(event.name, event.event);
}

Operations

Use operations for caller-visible work that may take time and report progress.

const ref = await client.operation.orders.process
  .start({ orderId: "ord_123" })
  .orThrow();

for await (const event of ref.watch()) {
  if (event.type === "progress") {
    console.log(event.snapshot.progress);
  }

  if (event.type === "completed") {
    console.log(event.snapshot.output);
    break;
  }
}

For the common case where you only need the terminal result:

const terminal = await ref.wait().orThrow();
console.log(terminal.output);

Service provider APIs

Services register handlers through service.handle. The handler receives typed input, request context, and a typed outbound client for the service’s allowed dependencies.

Bind application-owned dependencies once with service.with(deps) when handlers need local repositories, loggers, clocks, or projections. Trellis keeps those dependencies separate from request context and passes them as args.deps.

import { Result } from "@qlever-llc/trellis";

const app = service.with({ db, logger, clock, orders });

await app.handle.rpc.orders.get(async ({ input, context, client, deps }) => {
  deps.logger.info({ caller: context.caller }, "loading order");
  const entry = await client.kv.orders.get(input.orderId);
  if (entry.isErr()) return Result.err(entry.error);

  return Result.ok(entry.value.value);
});

Feed handlers receive an emit(...) helper and an abort signal.

await app.handle.feed.orders.live(async ({ input, emit, signal, deps }) => {
  while (!signal.aborted) {
    const update = await deps.orders.nextVisibleUpdate(input.customerId, signal);
    if (!update) break;
    await emit(update).orThrow();
  }
});

Operation handlers receive an operation control helper. Mark the operation started, publish progress if useful, then complete, fail, cancel, attach work, or defer completion to another durable path.

await app.handle.operation.orders.process(async ({ input, op, deps }) => {
  await op.started().orThrow();
  await op.progress({ step: "queued", message: "Queued" }).orThrow();

  const output = await deps.orders.process(input.orderId);
  return Result.ok(output);
});

The same binding applies to app.jobs.<queue>.handle(...) and app.event.<group>.<leaf>.listen(...). Registration options such as event subject data and listener options stay in their existing argument positions; do not pass dependencies as a settings object.

Result and AsyncResult

Trellis TypeScript APIs model expected failures with Result and AsyncResult from @qlever-llc/result, re-exported by @qlever-llc/trellis.

That means most calls do not throw for expected remote or validation failures. You can either branch explicitly or use .orThrow() when throwing is appropriate for your boundary.

const result = await client.rpc.orders.get({ orderId: "missing" });
const value = result.take();

if (value.isErr()) {
  console.warn(value.error.message);
  return;
}

console.log(value.value);

Use declared error classes for business failures callers are expected to handle. Use unexpected errors for bugs, infrastructure failures, or malformed responses.

Svelte integration

Svelte and SvelteKit apps should use @qlever-llc/trellis-svelte for app-local connection state and login integration.

import {
  createTrellisApp,
  type TrellisClientFor,
} from "@qlever-llc/trellis-svelte";
import contract from "$lib/contract";

type AppClient = TrellisClientFor<typeof contract>;

export const trellisApp = createTrellisApp({
  contract,
  trellisUrl: "http://localhost:3000",
});

export function getTrellis(): AppClient {
  return trellisApp.getTrellis();
}

Mount the same app owner with TrellisProvider near the Svelte app root. Read connection status from trellisApp.getConnection(), which returns a Svelte-reactive SvelteTrellisConnection.

Browser apps should pass the app source contract default export to Trellis helpers. The source contract returned by defineAppContract(...) already has the module shape Trellis needs: CONTRACT, CONTRACT_DIGEST, API.trellis, and contract metadata.

import { defineAppContract } from "@qlever-llc/trellis/contracts";
import { sdk as orders } from "#trellis-generated-sdk/orders";

const contract = defineAppContract(() => ({
  id: "acme.dashboard@v1",
  displayName: "Acme Dashboard",
  description: "Browser app for order workflows.",
  uses: {
    required: {
      orders: orders.use({
        rpc: { call: ["Orders.Get"] },
      }),
    },
  },
}));

export default contract;

If TypeScript reports that a contract is missing API or related contract properties, check whether the app passed the generated CONTRACT manifest constant instead of the app source contract default export. Avoid manually composing { CONTRACT, API: { owned: OWNED_API } } for normal Svelte app bootstrap; that partial shape can lose API.trellis, CONTRACT_DIGEST, state metadata, and clean inferred client types.

Generated SDKs are workspace artifacts. There is no separate published path for an app’s generated SDK; use them from source contracts for sdk.use(spec) and keep imports pointed at the local generated output or an import-map alias owned by the consuming repo.

For cross-contract app calls, declare the narrow uses selection the app needs and call the other contract SDK’s sdk.use(spec) helper. Do not depend on broad generated surfaces just to make client types appear.

Vite, npm, JSR, and svelte-check

Browser apps often combine Deno generation, JSR-style generated SDKs, npm packages, and Vite. Keep each resolver layer explicit:

  • Mirror @nats-io/* specifiers in deno.json so Deno generation and Vite/npm builds resolve the same transport packages.
  • Preserve generated npm literals such as npm:/typebox@^1.1.33/value; do not hand-normalize them to a different TypeBox entrypoint unless generation changes the source.
  • If Vite aliases an npm: Trellis specifier, use a regex find that catches subpaths as well as the package root, for example both npm:@qlever-llc/trellis and npm:@qlever-llc/trellis/....
  • If svelte-check cannot find Trellis types from the npm artifact, add a compilerOptions.paths mapping in the app’s typecheck config to the installed package mod.ts and its subpath .ts files. The npm artifact is not the source of generated app SDK types.

See Writing SvelteKit Apps for the full app flow, including login pages, providers, and local workspace aliases.

API reference

Use the API Reference for generated TypeScript docs and exact exported symbols. Guides show common patterns; /api is the source to consult when you need precise signatures.