# Trellis AI guidance for TypeScript services Last verified against Trellis main (2026-06-13). Use this file for AI coding agents working in a TypeScript service repository that consumes Trellis. The language-neutral guide is: https://raw.githubusercontent.com/qlever-llc/trellis/main/docs/static/llms-full.txt For Svelte browser apps, use: https://raw.githubusercontent.com/qlever-llc/trellis/main/docs/static/llms-svelte.txt ## Service repository setup Recommended TypeScript service shape: ```text services// |-- deno.json or package.json |-- contract.ts # Single-contract public entrypoint |-- contracts/*.ts # Optional multi-contract modules |-- schemas/*.ts # TypeBox wire schemas and exported model schemas |-- errors/*.ts # Service-local transportable errors |-- rpc/*.ts # RPC handlers |-- events/*.ts # Event listeners and publishers |-- operations/*.ts # Operation providers |-- db/ and migrations/ # Service-local storage and migrations |-- config.ts # Zod environment/config parsing `-- main.ts # Bootstrap, registration, shutdown ``` Rules: - single-contract services normally expose `contract.ts`; multi-contract services may use `contracts/*.ts` and optionally re-export the primary default contract from `contract.ts` - every TypeScript contract source loaded by Trellis generation should default-export the `defineServiceContract(...)`, `defineAppContract(...)`, `defineAgentContract(...)`, or `defineDeviceContract(...)` result - `deno.json` should expose Trellis generation through `deno task prepare` - the `prepare` task value should call the Trellis generator, for example `deno run -A @qlever-llc/trellis/generate prepare .` - generated SDKs, manifests, and package outputs belong under the service repo's generated output locations; do not edit generated files by hand - Trellis caches manifests by digest at runtime. If auth/bootstrap reports a missing digest after the cache is pruned, regenerate from source and present the full manifest; do not patch deployment authority or implementation offers by hand. - Deployment authority proposal/desired `needs` are grouped by `contracts`, `surfaces`, `capabilities`, and `resources`. Materialized authority is a projection with runtime `grants` grouped by `capabilities`, `surfaces`, and `nats`; service permissions require current materialization for the accepted desired authority version. Stale or obsolete persisted projections are repaired by Trellis storage upgrade and reconciliation. ## Use surface-first APIs Use generated facades on the connected client: ```ts const order = await client.rpc.orders.get({ orderId: "ord_123" }).orThrow(); await client.event.orders.shipped.publish({ orderId: "ord_123", customerId: "cust_456", shippedAt: new Date().toISOString(), }).orThrow(); const feed = await client.feed.orders.live({ customerId: "cust_456" }).orThrow(); const op = await client.operation.orders.process .start({ orderId: "ord_123" }) .orThrow(); ``` If a generated facade is missing, check the contract generation path or the current API reference. For cross-contract calls, declare a narrow `uses` selection and call the other contract SDK's `use(spec)` helper with only the blocks needed by this service. Avoid broad selections just to make generated client types appear. Connected TypeScript clients, devices, and services do not expose raw NATS handles. Use generated `rpc`, `event`, `feed`, `operation`, `state`, and `transfer` surfaces for communication, `service.kv` / `service.store` / `service.jobs` for service-owned resources, and `connection` for lifecycle status or shutdown. ## Register handlers with `service.handle` Register providers through the generated `service.handle` tree. Handler code receives typed input and a scoped `client` for allowed outbound calls. ```ts import { Result } from "@qlever-llc/trellis"; await service.handle.rpc.orders.get(async ({ input, client }) => { const row = await loadOrder(input.orderId); if (!row) return Result.err(orderNotFound(input.orderId)); await client.event.orders.viewed.publish({ orderId: input.orderId, viewedAt: new Date().toISOString(), }).orThrow(); return Result.ok(row); }); ``` The scoped `client` represents the service's allowed outbound dependencies. Register event listeners during service startup with `service.event`. When handlers need application-owned dependencies, bind them once during startup with `service.with(deps)` and register through the returned wrapper: ```ts const app = service.with({ db, logger }); await app.handle.rpc.orders.get(async ({ input, client, deps }) => { const row = await deps.db.orders.get(input.orderId); if (!row) return Result.err(orderNotFound(input.orderId)); deps.logger.info({ orderId: input.orderId }, "loaded order"); await client.event.orders.viewed.publish({ orderId: input.orderId }).orThrow(); return Result.ok(row); }); ``` ## Schemas and errors - Use TypeBox for contract wire schemas: RPC input/output, event payloads, feed input/events, operation input/output/progress, and resource declarations. - Event subject `params` are JSON Pointers into the event payload. They must resolve to string, number, or integer-compatible schema nodes. For `anyOf` or `oneOf` event payload schemas, every branch must contain each routed pointer. - Use Zod for environment and local configuration parsing. - Use Trellis pagination helpers for list RPCs instead of inventing new page envelopes. Offset pagination uses `PageRequestSchema`, `PageResponseSchema(entry)`, `normalizePageQuery(...)`, and `buildPageResponse(...)`. Stable ID/keyset pagination uses `CursorQuerySchema`, `CursorPageSchema(item)`, `normalizeCursorQuery(...)`, and `buildCursorPage(...)`. - Return declared business errors through `Result` values. Use `.orThrow()` only at process boundaries where throwing is intentional. ## Prepared events and outbox/inbox Direct publish is the default when no local transaction must be coupled to the event: ```ts await client.event.orders.shipped.publish({ orderId: input.orderId, customerId: input.customerId, shippedAt: new Date().toISOString(), }).orThrow(); ``` Use prepared events plus an outbox when a service-local database update and event enqueue must commit or roll back together: ```ts import { dispatchOutbox, SqlOutboxRepository, type SqlExecutor, } from "@qlever-llc/trellis/service"; import { Result } from "@qlever-llc/trellis"; const outboxRepository = new SqlOutboxRepository(dbExecutor, "postgres"); 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 markOrderShipped(tx, input.orderId); const executor: SqlExecutor = drizzleTransactionExecutor(tx); const outbox = new SqlOutboxRepository(executor, "postgres"); await outbox.enqueue(prepared); }); await dispatchOutbox(outboxRepository, client); return Result.ok({ accepted: true }); }); ``` For a long-running process-local dispatcher, use `OutboxDispatcher` over the same repository and a connected Trellis publisher that exposes `publishPrepared`. Use `SqlInboxRepository` only when replaying a message would repeat a non-idempotent side effect. Register the listener during service startup, not inside a handler. Use `NatsKvOutboxRepository` and `NatsKvInboxRepository` for services that do not have SQL state. They are durable helpers, but they are not atomic with unrelated database transactions. ## Durable event consumers Direct `uses.events.subscribe` grants only live or ephemeral listening. A service that needs replay, ordered processing, or a durable cursor must declare a top-level `eventConsumers` group in its contract and register the startup listener with that group. `eventConsumers..uses` selects dependency events by top-level `uses.required` or `uses.optional` alias; `eventConsumers..self` selects events owned by the same contract. ```ts const contract = defineServiceContract({ schemas }, () => ({ id: "shipment-projection@v1", displayName: "Shipment Projection", description: "Projects shipment events into local state.", uses: { required: { orders: orders.use({ events: { subscribe: ["Orders.Shipped"] }, }), }, }, eventConsumers: { shipmentProjection: { uses: { orders: ["Orders.Shipped"], }, replay: "new", ordering: "strict", concurrency: 1, }, }, })); await service.event.orders.shipped.listen(handleShipment, {}, { group: "shipmentProjection", }).orThrow(); ``` Dependency durable consumption remains authority-backed by the top-level `uses` subscription. For durable self-consumption of events owned by the same service, use `self: ["Entity.Observation"]`. Runtime durable consumers are Trellis-provisioned only; service code should not create or name arbitrary JetStream durable consumers. ## Verification Use the format, typecheck, lint, test, run, and migration commands configured by the service repository. If contract artifacts or generated SDKs changed, run `deno task prepare` and commit the generated outputs expected by that repo.