Serving runners
Host workflows in an HTTP service you already deploy: serve, register, the framework adapters, invoke signature verification, and the SDK error types.
serve builds an HTTP invoke handler from your workflows and Duraton POSTs invokes to it, so the
runner needs a publicly reachable URL. Use it when your runner is already an HTTP service you
deploy and expose; otherwise reach for connect, which needs no inbound URL.
import { serve } from "@duraton/sdk/bun";
const invoke = serve({
url: process.env.DURATON_URL, // your Duraton base URL, shown in the console
apiKey: process.env.DURATON_API_KEY,
signingKey: process.env.DURATON_SIGNING_KEY,
app: "support-app",
runnerUrl: "https://support.example.com/invoke", // where Duraton POSTs invokes
workflows: [ticketCreated],
});
Bun.serve({ port: Number(process.env.PORT), routes: { "/invoke": invoke } });serve
Returns a (req: Request) => Promise<Response> - a standard Fetch handler that runs on any
Fetch-native runtime. Passing any of url / app / runnerUrl turns on auto-registration: the
handler registers itself on startup and re-registers on a heartbeat, so you never call
register yourself. Pass none of the three for a bare handler that only answers invokes.
Prop
Type
An invoke body over 1 MiB is rejected with 413, matching Duraton's wire-message cap; a body that
is not a valid invoke request is rejected with 400.
Invoke signatures
Duraton signs every invoke it POSTs. The signature rides the X-Duraton-Signature header as
t=<unix-seconds>&s=<hex>, where s is an HMAC-SHA256 over the exact string
`${t}.${rawBody}` keyed by your project's signing key. serve verifies it before parsing the
body: it recomputes the HMAC, compares in constant time, and rejects the request with 401 when the
signature is missing, malformed, mismatched, or its timestamp is more than 300 seconds from now.
const invoke = serve({
signingKey: process.env.DURATON_SIGNING_KEY, // the signing key from your project's settings
app: "support-app",
runnerUrl: "https://support.example.com/invoke",
workflows: [ticketCreated],
});A signing key is required: serve throws on startup unless signingKey or DURATON_SIGNING_KEY is
set, since the signature check is the only thing that stops a forged invoke. Get the key from your
project's settings in the Duraton console and set it on every runner you expose. To serve without a
signature check on purpose - a private network, a local test - pass allowUnsignedInvokes: true
instead of a key.
The runner never sends the key back to Duraton. At registration it reports only a one-way fingerprint of it, which Duraton compares against its own so the console can flag a runner whose key has drifted.
Signing key and rotation
Each project has its own signing key. Get it from the Duraton console, on your project's settings,
and set it on every serve runner as DURATON_SIGNING_KEY (or pass it as the signingKey option):
export DURATON_SIGNING_KEY="..."A runner started with connect needs no signing key - it authenticates with
your API key when the WebSocket opens, so signing keys apply only to serve (inbound HTTP) runners.
Rotating the key is zero-downtime. When you rotate it in the console, Duraton issues a new key and
keeps signing invokes with the previous key too for a 24-hour window. Update
DURATON_SIGNING_KEY on your runners to the new value any time inside that window - both keys verify,
so no invoke is rejected while you roll the new value out and redeploy at your own pace. Once the
window closes, only the new key is accepted, and a runner still on the old key starts failing
verification. So the rotation flow is: rotate the key in the console, then update
DURATON_SIGNING_KEY on every serve runner within 24 hours.
Framework adapters
Each adapter takes the same options as serve (so it auto-registers the same way) and returns that
framework's native handler. The route defaults to /invoke; mount it elsewhere and set runnerUrl to match.
import { serve } from "@duraton/sdk/hono";
app.post("/invoke", serve({
app: "support-app",
runnerUrl: "https://support.example.com/invoke",
workflows: [ticketCreated],
}));| Framework | Import | Mount |
|---|---|---|
| Bun | @duraton/sdk/bun | Bun.serve({ routes: { "/invoke": serve(opts) } }) |
| Hono | @duraton/sdk/hono | app.post("/invoke", serve(opts)) |
| Elysia | @duraton/sdk/elysia | new Elysia().post("/invoke", serve(opts)) |
| Express | @duraton/sdk/express | app.post("/invoke", serve(opts)) |
| Fastify | @duraton/sdk/fastify | app.register(serve(opts)) |
| Next.js | @duraton/sdk/next | export const { POST } = serve(opts) |
| Node (http) | @duraton/sdk/node | createServer(toNodeHandler(opts)) |
Mount the adapters that read the raw request - Express, Fastify, Node - before any body parser, so
the bytes Duraton signed reach the handler intact and the HMAC still verifies. The Fastify adapter
scopes its own raw-body parser, so that one is automatic. Deno and Cloudflare Workers are Fetch-native:
use the serve handler directly as the runtime's fetch.
register
serve's auto-registration covers most cases. Call register directly to register a serve-mode runner
yourself - to control exactly when it happens, or from a process that builds the handler elsewhere. It
is idempotent, so it is safe on every startup.
import { register } from "@duraton/sdk";
await register({
url: process.env.DURATON_URL,
apiKey: process.env.DURATON_API_KEY,
app: "support-app",
runnerUrl: "https://support.example.com/invoke",
workflows: [ticketCreated],
});Prop
Type
A failed registration throws - register does not swallow a non-2xx response.
Configuration
The runner reads these from its own environment. Each is an override of the matching option, so a
process that sets them needs no options at all beyond workflows and app.
export DURATON_URL="https://run.duraton.dev"
export DURATON_API_KEY="dtn_live_..."
export DURATON_SIGNING_KEY="..."
export DURATON_RUNNER_URL="https://support.example.com/invoke"| Env var | Backs | Description |
|---|---|---|
DURATON_URL | url | Your Duraton base URL, shown in the console. |
DURATON_API_KEY | apiKey | The project API key the runner authenticates with. |
DURATON_SIGNING_KEY | signingKey | The key invoke signatures are verified against. |
DURATON_RUNNER_URL | runnerUrl | The public URL Duraton POSTs invokes to. |
DURATON_APP | app | The app this runner registers under; "default" when unset. |
DURATON_REGION | RunnerMeta.region | A region label reported at registration, shown on the runner in the console. |
The route path and the registered runnerUrl must agree: mount the handler somewhere other than /invoke
and runnerUrl must be the full address Duraton should call.
Middleware
middleware is a thin lifecycle hook that wraps every run of your handler. It has two optional
hooks, and either transport - serve or connect - accepts the same object:
onInvokeruns before your handler and returns log bindings that are attached to every log line the run emits - a per-run child logger, without threading context through your code.onResultruns after your handler settles and can transform what leaves the process: a sanitized error, a redacted result.
import { serve } from "@duraton/sdk/bun";
import type { Middleware } from "@duraton/sdk";
const middleware: Middleware = {
onInvoke: (info) => ({ runId: info.runId, attempt: info.attempt }),
onResult: (info, outcome) => {
if (!outcome.ok) return outcome; // leave failures alone
return { ok: true, result: redact(outcome.result) };
},
};
const invoke = serve({ app: "support-app", workflows: [ticketCreated], middleware });onResult fires only on a terminal outcome - when your handler returns or throws - never while
the run is still discovering steps. A durable handler re-runs top to bottom on every step it
discovers, so a hook that fired mid-run would run its "final" transform many times per run.
onInvoke runs on each of those passes (it only enriches log context, touching no durable state),
so keep it cheap and side-effect-free.
InvokeInfo
Both hooks receive the same read-only description of the run.
Prop
Type
onResult and Outcome
onResult(info, outcome) receives an Outcome discriminating a handler that returned from one that
threw, and returns a replacement Outcome to transform it - or nothing to leave it unchanged.
type Outcome =
| { ok: true; result: unknown }
| { ok: false; error: unknown };
type OnResult = (info: InvokeInfo, outcome: Outcome) => Outcome | void | Promise<Outcome | void>;Returning the other variant crosses the outcome over: return { ok: false, error } from a success to
fail a run that returned, or { ok: true, result } from a failure to swallow a handler error
into a success. The common case is neither - you sanitize the error or redact the result and keep the
same ok.
Built-in middlewares
Two ready-made hooks cover the common cases. A single Middleware object can carry both an
onInvoke and an onResult, so compose them by spreading:
import { bindLogContext, sanitizeErrors } from "@duraton/sdk";
const middleware = {
...sanitizeErrors(),
...bindLogContext((info) => ({ runId: info.runId, attempt: info.attempt })),
};
connect({ app: "support-app", workflows: [ticketCreated], middleware });sanitizeErrors()- anonResultthat replaces any thrown error with one that keeps the message but drops the stack, so internal frames never leave the process.bindLogContext(fn)- anonInvokethat attachesfn(info)to every log line the run emits. A per-call field onctx.logwins over a binding of the same key.
Middleware is available in the TypeScript SDK today. Python and Go parity is on the roadmap.
Errors
Throw these from inside a step to control retry behaviour. Any other thrown error is retried per the
workflow's retry policy.
NonRetriableError
Fails the run immediately, skipping any attempts the retry policy would still allow. Use it for failures a retry cannot fix - a validation error, a missing config value.
await ctx.step.run("validate", () => {
if (!ticket.email) throw new NonRetriableError("ticket has no requester email");
return ticket;
});new NonRetriableError(message: string, options?: { cause?: unknown })RetryAfterError
Retries the step after a delay you choose instead of the policy's computed backoff. It does not
grant extra attempts: once maxAttempts is reached the run fails as usual.
await ctx.step.run("call-vendor", async () => {
const res = await fetch(vendorUrl);
if (res.status === 429) throw new RetryAfterError("vendor rate limit", "30s");
return res.json();
});new RetryAfterError(message: string, retryAfter: string | number | Date, options?: { cause?: unknown })retryAfter is a duration string ("30s"), a number of milliseconds, or an absolute Date. The
resolved delay is exposed as retryAfterMs.
PollTimeoutError
Thrown by step.poll when a resource does not become ready before its timeout.
Unhandled, it fails the run like any terminal error; catch it to treat a missed deadline as a
non-fatal branch.
try {
await ctx.step.poll("provision", () => fetchRecordOrNull(), { every: "5s", timeout: "10m" });
} catch (err) {
if (err instanceof PollTimeoutError) return { status: "still-provisioning" };
throw err;
}connect
Run your workflow code from behind NAT, a laptop, or a container with no ingress - connect dials out over a WebSocket, so there is no inbound URL to expose.
REST client
Drive Duraton from your app code: createClient is a typed wrapper over the HTTP API to trigger events and read or control runs.