Your first durable job
Read the commerce Orders export task, its public job, provider binding, and accepted run handle.
Define an Orders export task, expose it through a job, and follow the run to completion. It is an optional extension after
the Orders API tutorial, not a file replacement in your protected
relkit-orders app. You need Bun, repository dependencies, and Docker for a live provider run.
Pre-setup
Use a native task provider in local Docker. Inngest is the starting path here; Trigger.dev and Effect MQ also have Docker recipes. Choose one and review its capabilities.
Install Inngest and local tooling
From this guide’s application/package root, install only its selected requirements:
bun add @relkit/docker@0.7.12 @relkit/inngest@0.8.1 @relkit/local@0.7.12 inngest@4.20.0Keep these owner-specific versions and the lockfile together. Runtime SDKs belong in dependencies; build/type tooling belongs in devDependencies. Installing an adapter does not supply its optional SDK peers or enable the feature’s configuration.
Configure the jobs profile
Merge jobs and defaults.jobs into your root configuration. Keep your existing ID,
env import and other settings; the example imports the environment contract from src/platform/env.ts.
/** Declares task application defaults with inspection disabled until selected. */
import { defineApp } from "@relkit/app/config";
import env from "@app/platform/env.js";
import { docker } from "@relkit/docker";
import { inngest } from "@relkit/inngest";
export default defineApp({
id: "relkit-inngest",
env,
server: { port: 3000, maxBodyBytes: 1_048_576 },
telemetry: { redaction: { mode: "development-redacted", maxBytes: 65_536 } },
jobs: { default: docker(inngest()) },
defaults: { jobs: "default" },
});Docker supplies the local provider’s connection values; there are no new defineEnv secrets
for this recipe. Jobs with profile: "default" use this binding. localJob() is the older
function-target provider and is not a substitute for the native task provider used here.
Check and start after registering the task and job
Check Docker before continuing. After adding the resource and its service registration, run from the app root:
docker version
bun run check
bun run typecheck
bun run devcheck validates the graph; it starts no containers. dev starts only Docker recipes used by
that graph, waits for readiness, and binds their generated connection values. An unused profile
alone does not start a container. Keep the server running and use bun run relkit local status
in another terminal to inspect the services. Local Docker
covers detached startup and stopping services while retaining their volumes.
Use bun run relkit jobs capabilities to inspect the selected provider, then follow
Your first durable job to submit and observe a run.
1. Define a task
When adapting this to a separate lean app, first complete Inngest’s selected installation and binding. The repository example already declares its native SDK and local tooling; copying task/job files does not install them. Provider creation tuples remain unavailable.
In your own application, with an existing orders service and no export task yet, run:
bun run relkit add task export-orders --service ordersThis creates src/services/orders/tasks/export-orders.task.ts and registers exportOrdersTask in
the service’s task map. Replace its placeholder input, output, and handler with your operation.
The task command reference lists execution and version options.
Skip scaffolding when reading the commerce example, which already contains these files.
A task owns durable execution. This one accepts order IDs, emits progress, and returns CSV text.
Its helper exportOrderCsv lives in the commerce example; if adapting the pattern to your app,
replace that helper with your own business operation and keep its schemas explicit.
/** Produces demonstration CSV through the ordinary order helper and emits validated progress around the durable export. */
import { defineTask } from "@relkit/app/tasks";
import { z } from "@relkit/app/schema";
import { exportOrderCsv } from "../helpers/fake-business.js";
const exportOrders = defineTask({
id: "orders.export-orders",
version: "1",
execution: "durable",
input: z.object({ orderIds: z.array(z.string()) }),
output: z.object({ csv: z.string(), exported: z.number().int().nonnegative() }),
progress: z.object({
completed: z.number().int().nonnegative(),
total: z.number().int().positive(),
}),
handler: async ({ orderIds }, context) => {
await context.progress.emit({ completed: 0, total: orderIds.length });
const csv = exportOrderCsv(orderIds);
await context.progress.emit({ completed: orderIds.length, total: orderIds.length });
return { csv, exported: orderIds.length };
},
});
export default exportOrders;The task ID and version are stable compatibility identifiers. Change the version when the input or output contract is intentionally incompatible.
2. Expose a job
In your own app, scaffold the job after creating the task:
bun run relkit add job export-orders --service orders \
--target orders.export-orders-task--target is the task’s stable ID. This creates src/services/orders/jobs/export-orders.job.ts
and registers the job on the service. Review its provider profile and client policy before
running it. It does not create the HTTP trigger below. See the
job command reference for all flags.
The job gives the task a public name and client policy. The HTTP route below is a separate
entry point; its handler accepts a run and returns 202, not the CSV output.
/** Registers the export task as a public job exposing trigger, get, list and watch with progress/output fields. */
import { defineJob } from "@relkit/app/jobs";
import exportOrders from "../tasks/export-orders.task.js";
const exportOrdersJob = defineJob({
name: "exportOrders",
id: "orders.export-orders",
task: exportOrders,
client: {
public: true,
operations: ["trigger", "get", "list", "watch"],
fields: ["status", "progress", "output"],
},
});
export default exportOrdersJob;Use this job from the frontend
After frontend setup and pulling the commerce contract, render
this component under the provider. It selects this job’s public exportOrders name:
"use client";
/** Submits one commerce export and observes the accepted run without replaying an uncertain submission. */
import { useJobRun, useJobTrigger, useRelkitClient } from "@relkit/client/react";
/** Keeps the accepted receipt separate from the task's eventual output.
* @returns One explicit submission button and its receipt, observation, or failure state. */
export function ExportOrders() {
const { status } = useRelkitClient();
const submit = useJobTrigger("exportOrders");
const run = useJobRun("exportOrders", submit.data ? { runId: submit.data.runId } : {});
return (
<section>
<button
disabled={status !== "ready" || !submit.isIdle}
onClick={() => submit.mutate({ input: { orderIds: ["order-1"] } })}
>
Export orders
</button>
{submit.isPending && <p role="status">Submitting…</p>}
{submit.isError && (
<p role="alert">{String(submit.error)}. Check the outcome before submitting again.</p>
)}
{submit.data && <p>Run ID: {submit.data.runId}</p>}
<p role="status">
Connection: {run.connection}; run: {run.run?.status ?? "not accepted"}
</p>
</section>
);
}The task determines the orderIds input. The job’s client.operations permits triggering and
observation, while client.fields selects public run data. The returned runId is an admission
receipt; completion and output arrive through the run observer. The example keeps uncertain
submissions visible instead of automatically sending them again.
Reference: useJobTrigger, useJobRun.
Continue with job clients and recovery.
Add a separate HTTP trigger
/**
* Accepts an order export through the public orders service and returns its durable
* run receipt. The service owns the job descriptor; this route owns HTTP transport.
*/
import { defineRoute } from "@relkit/app/routes";
import orders from "../../../services/orders/service.js";
export const POST = defineRoute({
id: "orders.export",
handler: async (request) => {
const input = (await request.json()) as { readonly orderIds: string[] };
const run = await orders.exportOrders.trigger(input);
return Response.json(run, { status: 202 });
},
});The commerce Orders service exports both the task and job. Its relkit.config.ts binds
jobs.default to docker(inngest()), so the compiler can check durable capabilities. When
adapting this to your own app, add a supported job binding before starting a run.
The generated job owns admission and client operations while the task remains the unit of execution and compatibility.
For the browser side of this declaration, follow the
React trigger and run example. It uses this job’s
exportOrders name and the task’s generated input/output types.
3. Trigger and observe
The generated trigger receives { input, options } and returns an accepted RunHandle. For example,
call client.jobs.exportOrders.trigger({ input: { orderIds }, options: { operationId } }), then read
client.jobs.exportOrders.runs.get({ runId: accepted.runId }). Keep the accepted runId and
operationId; a handle is an admission receipt, not the task output.
Use job clients for the complete trigger/watch lifecycle and progress and streams for live updates.
4. Run locally
bun run check
bun run typecheck
bun ../../packages/cli/dist/bin.js local up --detach
bun ../../packages/cli/dist/bin.js local statusStart bun run dev after the provider reports ready. In another terminal, submit a run:
bun ../../packages/cli/dist/bin.js jobs capabilities
curl -i -X POST http://127.0.0.1:4000/orders/export \
-H 'content-type: application/json' \
-d '{"orderIds":["order-1"]}'Expect HTTP 202 and a RunHandle with a runId; read or watch that ID until completion.
The local provider is
selected by the project binding. Use relkit local reset --dry-run before clearing local state.
Run bun run test from examples/commerce before changing the task. A passing test checks
application behavior, while the Docker run checks the selected provider.