This tutorial walks through building a TypeScript service from scratch using published Trellis packages. By the end you will have a working service that connects to NATS, mounts an RPC handler, and subscribes to events.
Before starting, read Trellis Concepts for background on contracts, RPCs, events, and packages.
What you need
- a running Trellis environment from Starting Trellis
- Deno (or Node) installed
- the
trellisCLI installed (see Install the Trellis CLI)
1. Create the project
Your service repository is completely separate from the Trellis source tree:
mkdir my-service && cd my-service
deno init Add the Trellis packages your service needs to your import map in deno.json:
{
"imports": {
"@qlever-llc/trellis-contracts": "npm:@qlever-llc/trellis-contracts",
"@qlever-llc/trellis-server": "npm:@qlever-llc/trellis-server",
"@qlever-llc/trellis-server/deno": "npm:@qlever-llc/trellis-server/deno",
"@qlever-llc/trellis-sdk-auth": "npm:@qlever-llc/trellis-sdk-auth",
"@qlever-llc/trellis-sdk-core": "npm:@qlever-llc/trellis-sdk-core",
"@qlever-llc/trellis-result": "npm:@qlever-llc/trellis-result",
"@qlever-llc/trellis-telemetry": "npm:@qlever-llc/trellis-telemetry",
"@sinclair/typebox": "npm:@sinclair/typebox"
}
} If you are using Node instead, install these with npm install.
2. Define your schemas
Create schemas.ts with the TypeBox schemas for your RPCs and events:
import { type Static, Type } from "@sinclair/typebox";
export const MyListRequestSchema = Type.Object({
limit: Type.Optional(Type.Number({ default: 50 })),
});
export type MyListRequest = Static<typeof MyListRequestSchema>;
export const MyListResponseSchema = Type.Object({
items: Type.Array(Type.Object({
id: Type.String(),
name: Type.String(),
})),
limit: Type.Number(),
});
export type MyListResponse = Static<typeof MyListResponseSchema>;
export const MyItemCreatedEventSchema = Type.Object({
id: Type.String(),
name: Type.String(),
createdAt: Type.String(),
});
export type MyItemCreatedEvent = Static<typeof MyItemCreatedEventSchema>; 3. Define the contract
Create contract.ts. The contract declares every RPC, event, dependency, and resource your service uses:
import { defineContract } from "@qlever-llc/trellis-contracts";
import { HealthResponseSchema, HealthRpcSchema } from "@qlever-llc/trellis-server";
import { auth } from "@qlever-llc/trellis-sdk-auth";
import { core } from "@qlever-llc/trellis-sdk-core";
import {
MyItemCreatedEventSchema,
MyListRequestSchema,
MyListResponseSchema,
} from "./schemas.ts";
export const myService = defineContract({
id: "my-service@v1",
displayName: "My Service",
description: "Serve list RPCs and publish item creation events.",
kind: "service",
uses: {
core: core.use({
rpc: { call: ["Trellis.Bindings.Get", "Trellis.Catalog"] },
}),
auth: auth.use({
events: { subscribe: ["Auth.Connect"] },
}),
},
resources: {
kv: {
items: {
purpose: "Store item records",
history: 1,
ttlMs: 0,
},
},
},
rpc: {
"MyService.Health": {
version: "v1",
inputSchema: HealthRpcSchema,
outputSchema: HealthResponseSchema,
capabilities: { call: [] },
errors: ["UnexpectedError"],
},
"MyService.List": {
version: "v1",
inputSchema: MyListRequestSchema,
outputSchema: MyListResponseSchema,
capabilities: { call: ["admin"] },
errors: ["ValidationError", "UnexpectedError"],
},
},
events: {
"MyService.ItemCreated": {
version: "v1",
eventSchema: MyItemCreatedEventSchema,
capabilities: {
publish: ["service"],
subscribe: ["service"],
},
},
},
});
export const { CONTRACT_ID, CONTRACT, CONTRACT_DIGEST, API, use } = myService; Key things to note:
usesdeclares cross-contract dependencies — without these, Trellis will not grant access to those RPCs/eventsresources.kv.itemsrequests a KV bucket with the logical aliasitems— Trellis assigns the physical name at install timecapabilitiescontrols who can call each operation
4. Write the service entry point
Create main.ts:
import { connectService } from "@qlever-llc/trellis-server/deno";
import { myService } from "./contract.ts";
const service = await connectService(myService, "my-service", {
sessionKeySeed: Deno.env.get("MY_SERVICE_SESSION_KEY_SEED")!,
nats: {
servers: Deno.env.get("NATS_SERVERS") ?? "localhost",
sentinelCredsPath: Deno.env.get("NATS_SENTINEL_CREDS"),
},
server: {},
}); connectService automatically verifies that the contract is active in the Trellis catalog and resolves all resource bindings declared in the contract. If the contract is not installed, it throws with a clear error message.
For Node, change the import to @qlever-llc/trellis-server/node and replace Deno.env.get(...) with process.env.
5. Open KV stores
Resource bindings are resolved during connectService and exposed as handles on the service object. Open a typed KV store directly from the handle:
import { isErr } from "@qlever-llc/trellis-result";
import { MyItemSchema } from "./schemas.ts";
const result = await service.kv.items.open(MyItemSchema);
const itemsKV = result.take();
if (isErr(itemsKV)) throw itemsKV.error; No need to know bucket names or configure history/TTL — those come from the installed contract binding.
6. Mount RPC handlers
Mount handlers that match the operations declared in your contract:
await service.trellis.mount("MyService.List", async (req) => {
return Result.ok({
items: [],
limit: req.limit ?? 50,
});
}); 7. Subscribe to events
Subscribe to events from other contracts that you declared in uses:
await service.trellis.event("Auth.Connect", {}, async (event) => {
console.info("user connected", { origin: event.origin, id: event.id });
return Result.ok(undefined);
}); 8. Handle shutdown
Deno.addSignalListener("SIGTERM", async () => {
await service.stop();
Deno.exit(0);
}); For Node, use process.on("SIGTERM", ...).
9. Build and verify contract artifacts
Keep the contract source with your service and generate artifacts into build output:
my-service/
contracts/my_service.ts
schemas.ts
main.ts
dist/contracts/
generated/sdk/ Build and verify it with the CLI:
trellis contracts build \
--source ./contracts/my_service.ts \
--out-manifest ./dist/contracts/trellis.my-service@v1.json
trellis contracts verify --source ./contracts/my_service.ts 10. Generate an SDK for consumers
Other services and apps that want to call your RPCs or subscribe to your events need a typed SDK package. Generate one from the contract source as part of the same build:
trellis contracts build \
--source ./contracts/my_service.ts \
--out-manifest ./dist/contracts/trellis.my-service@v1.json \
--ts-out ./generated/sdk/ts \
--package-name "@my-org/sdk-my-service" This produces a Deno package under ./generated/sdk/ts/. The generated SDK version matches the version declared by the workspace that owns the contract source. It includes:
mod.ts— the contract module anduse(...)exporttypes.ts— typed request, response, and event definitionsschemas.ts— JSON schemas extracted from the manifestcontract.ts— contract ID and digest constantsscripts/build_npm.ts— a build script that usesdntto produce an npm package
Consumers import the SDK like any other Trellis contract package:
import { myService } from "@my-org/sdk-my-service";
// Declare a dependency on your service
const app = defineContract({
// ...
uses: {
myService: myService.use({
rpc: { call: ["MyService.List"] },
}),
},
}); Regenerate the SDK whenever the contract changes. This is the same pattern that @qlever-llc/trellis-sdk-auth and @qlever-llc/trellis-sdk-core follow.
11. Install and run
Install the contract into your Trellis environment directly from source during development:
trellis service install --source ./contracts/my_service.ts If you ship the service as an OCI image, embed the generated contract artifact in the image and label its path so operators can install directly from the image:
FROM docker.io/denoland/deno:2.7.7
WORKDIR /app
LABEL io.trellis.contract.path="/trellis/contract.json"
COPY . .
COPY dist/contracts/trellis.my-service@v1.json /trellis/contract.json
CMD ["deno", "run", "--allow-net", "--allow-env", "main.ts"] Then operators can install or upgrade from the image instead of checking out the repo:
trellis service install --image ghcr.io/my-org/my-service:1.2.3
trellis service upgrade --image ghcr.io/my-org/my-service:1.2.4 Trellis looks for the contract at the labeled path first and falls back to /trellis/contract.json when no label is present.
Then run the service:
MY_SERVICE_SESSION_KEY_SEED=<seed> \
NATS_SERVERS=localhost \
NATS_SENTINEL_CREDS=./path/to/sentinel.creds \
deno run --allow-net --allow-env main.ts Development loop
- Update schemas and the
defineContract(...)module - Rebuild contract artifacts and SDKs with
trellis contracts build - Upgrade the contract in Trellis if the contract changed
- Restart the service