The same application, a second runtime
Tutorial. The final hands-on lesson. It assumes you have
GreetingModuleandGreeteringreeter.ts— any lesson of the arc since Getting started leaves them there, and the worker composes the module, not its internals, so it does not matter which version you arrived with. We keep explanation to a minimum here and link out to it.
By the end you will have two main.ts files sharing one application module: the HTTP service from lesson one, and a Temporal worker that runs the same Greeter as an activity. That is the kernel's first thesis made concrete — one process boots one runtime, and a second deployment is a second composition root, not a second flag (why).
Step 1 — Install the Temporal starter
pnpm add @btravstack/temporal-worker @temporalio/worker @temporalio/activity @temporalio/common @temporal-contract/worker@^8.0.0-beta @temporal-contract/contract@^8.0.0-beta zodnpm install @btravstack/temporal-worker @temporalio/worker @temporalio/activity @temporalio/common @temporal-contract/worker@^8.0.0-beta @temporal-contract/contract@^8.0.0-beta zodyarn add @btravstack/temporal-worker @temporalio/worker @temporalio/activity @temporalio/common @temporal-contract/worker@^8.0.0-beta @temporal-contract/contract@^8.0.0-beta zod@btravstack/core, config, di and unthrown are already there from lesson one; the rest are @btravstack/temporal-worker's peers. zod is for the contract, the same as lesson one's — and it earns its place twice over here, because Temporal persists every input and output and replays them later.
You also need a Temporal service to poll. The Temporal CLI ships one for development:
temporal server start-devStep 2 — Write the contract
Where lesson one declared an oRPC procedure, this declares an activity and the workflow that calls it, on a named task queue:
temporal-contract.ts
import {
defineActivity,
defineContract,
defineWorkflow,
} from "@temporal-contract/contract";
import { z } from "zod";
const greet = defineActivity({
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
activityOptions: { startToCloseTimeout: "1 minute" },
});
const greeting = defineWorkflow({
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
startPolicy: "allow-duplicate",
activities: { greet },
});
export const greetingContract = defineContract({
taskQueue: "greetings",
workflows: { greeting },
});taskQueue is part of the contract because a worker's identity is its task queue — the starter reads it from here rather than taking it as an option.
Step 3 — Implement the activity
TemporalActivities(contract) is the Temporal twin of OrpcRouter: di's own Provider(port) on the starter's activities port, typed for the contract — its service is the contract's activities record — so the next call declares its dependencies exactly as the router did:
activities.ts
import { TemporalActivities } from "@btravstack/temporal-worker";
import { OkAsync } from "unthrown";
import { Greeter } from "./greeter.js";
import { greetingContract } from "./temporal-contract.js";
export const greetingActivities = TemporalActivities(greetingContract)({
inject: { greeter: Greeter },
sync: ({ greeter }) => ({
greeting: {
greet: ({ input }) => OkAsync({ message: greeter.greet(input.name) }),
},
}),
});Same Greeter, same greet, a different transport around it. The activity is a closure over the service its provider declared — no context is read at call time.
Step 4 — Write the workflow
Workflow code runs in Temporal's deterministic sandbox, bundled separately from the worker, so it lives in its own file and touches neither di nor the Greeter — only the activity:
workflows.ts
import {
declareWorkflow,
propagateFailure,
} from "@temporal-contract/worker/workflow";
import { greetingContract } from "./temporal-contract.js";
export const greeting = declareWorkflow({
workflowName: "greeting",
contract: greetingContract,
implementation: (context, args) =>
propagateFailure(context.activities.greet({ name: args.name })),
});propagateFailure hands an activity's platform failure — retries exhausted, cancelled — back to Temporal untouched. The contract declared no errors of its own, so there is nothing else to triage.
Step 5 — Compose the second root
TemporalModule(name)({...}) is HttpModule's twin: a Module(name)({...}) that also takes the contract, the activities provider and where the workflow code lives, imports the Temporal starter, and exports TemporalRuntime:
worker.ts
import { TemporalModule } from "@btravstack/temporal-worker";
import { workflowsPathFromURL } from "@temporal-contract/worker/worker";
import { greetingActivities } from "./activities.js";
import { GreetingModule } from "./greeter.js";
import { greetingContract } from "./temporal-contract.js";
export const Worker = TemporalModule("Worker")({
contract: greetingContract,
activities: greetingActivities,
workflows: {
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
},
imports: [GreetingModule],
});Compare it with app.ts from lesson one. imports: [GreetingModule] is the same line; what changed is the starter around it. And the same gate holds: drop the import and TemporalModule(...) stops compiling, because the activities provider declares Greeter.
Step 6 — Write the second main.ts
worker-main.ts
import { runMain } from "@btravstack/core";
import { Worker } from "./worker.js";
await runMain(Worker);Identical to lesson one's, down to the import. Run it:
npx tsx src/worker-main.tsTEMPORAL_ADDRESS (default 127.0.0.1:7233) and TEMPORAL_NAMESPACE (default default) are read inside the graph, the way PORT was; the connection is a resource of the graph, opened with the scope and closed on every exit path. A service that will not answer is a modeled TemporalUnreachable and exit code 1 — an operator can act on it — not a crash.
Step 7 — Start a workflow
Name the workflow id yourself, so the second command has something to ask about — start prints the one it minted otherwise, and you would be copying it back out of the output:
temporal workflow start \
--task-queue greetings \
--type greeting \
--workflow-id greeting-1 \
--input '{"name":"world"}'
temporal workflow result --workflow-id greeting-1The worker picks the task up, runs greet through the same Greeter lesson one served over HTTP, and answers {"message":"Hello, world!"}.
A workflow id is yours to choose and yours to reuse: Temporal refuses a second run with an id that is already running, which is the deduplication a job queue would ask you to build. Run the pair again with the same id after it has finished and you get a second run; run it while the first is still going and Temporal says so.
What you now have
Two entry points, one application:
src/greeter.ts GreetingModule — the application, knows no transport
src/main.ts runMain(App) — HTTP: PORT, HOST
src/worker-main.ts runMain(Worker) — Temporal: TEMPORAL_ADDRESS, TEMPORAL_NAMESPACEEach is a process of its own: it scales, fails and deploys independently, and there is never a question of how two runtimes in one process would share a drain deadline. What a running process is doing is readable from the outside too — start's RunningApp.runtimeInfo() resolves what the runtime published about itself once it is serving: { port } for the HTTP one, { taskQueue, namespace } for the worker.
Where next
- One process, one runtime — the reasoning, and what a multi-runtime process would have cost.
- Run a Temporal worker — the full starter: pinning,
gracePeriod/forceAfter, and how a domain error becomes anonRetryablecontract error. - Order Temporal worker — the same shape with a real saga behind it.