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.

createClient is how code outside a runner talks to Duraton: trigger events, read and control runs, tail the event log, and read the numbers behind the console's charts - all typed, over plain HTTP.

import { createClient } from "@duraton/sdk/client";

const duraton = createClient({
  url: process.env.DURATON_URL!,   // your Duraton base URL, shown in the console
  apiKey: process.env.DURATON_API_KEY,
});

Prop

Type

A public key can call the GET-backed methods. Writes - events.send, cancel, pause, resume, replay, retryFromStep, fork, bulkReplay, score, and the dataset methods - need a secret key.

events

const res = await duraton.events.send({
  name: "ticket.created",
  app: "support-app",
  data: { ticketId: "T-421" },
});
res.runId;       // the run started, when exactly one workflow matched
res.triggered;   // one entry per matched workflow

const events = await duraton.events.list({ app: "support-app", limit: 20 });
const one = await duraton.events.get(events[0].id);

// Live tail (Server-Sent Events). Iterate to consume; abort to stop.
const ac = new AbortController();
for await (const ev of duraton.events.stream({ signal: ac.signal })) {
  console.log(ev.name, ev.triggered);
}

runs

// One page, newest first, plus the keyset cursor for the next page.
const page = await duraton.runs.list({ status: "failed", sort: "duration", dir: "desc", limit: 20 });
page.runs;
page.nextCursor;   // pass back as { cursor } for the next page, or null on the last

// Walk every run across pages - the cursor is managed for you.
for await (const run of duraton.runs.listAll({ app: "support-app" })) {
  console.log(run.id, run.status);
}

const run = await duraton.runs.get("01HXYZ...");
const steps = await duraton.runs.steps("01HXYZ...");
const stats = await duraton.runs.stats({ app: "support-app" });

// Look one run up by a business tag instead of its id - returns the match, or null.
const ticket = await duraton.runs.find({ tags: { ticketId: "123" } });

list accepts the same filters as the runs API: app, workflow, status, runType, eventId, session, replayOf, scoreName, minScore, maxScore, q, deep, since (a string or Date), sort, dir, limit, cursor.

find(opts) is the single-run lookup: the same filters as list minus pagination, capped at one row, returning Run | null. When several runs match, sort/dir pick which one - the default is the most recent.

Trigger and await

runs.wait polls a run until it reaches a stop status and returns it - the "fire an event, get the result" primitive. It stops at any terminal status by default; pass until to also stop at a resting state like waiting or paused.

const { runId } = await duraton.events.send({ name: "ticket.created", data: { ticketId: "T-421" } });
const run = await duraton.runs.wait(runId!, { timeoutMs: 60_000 });

if (run.status === "succeeded") console.log(run.result);
else console.error(run.error);

Prop

Type

wait rejects on timeout or an aborted signal - not on an unhappy outcome. A run that failed or was cancelled resolves normally, so branch on run.status.

Live tails

// A run's timeline: status transitions and ctx.log lines, in ticket. Ends when the run is
// terminal; { from } resumes past a seq.
for await (const frame of duraton.runs.watch("01HXYZ...")) {
  if (frame.kind === "log") console.log(frame.level, frame.message);
  else console.log(frame.kind, frame.status);
}

// A run's persisted ctx.log history, oldest first; page forward with { from }.
const lines = await duraton.runs.logs("01HXYZ...", { from: 0, limit: 100 });

// Run status changes across the whole project. Only run_status frames flow and there is no
// per-run cursor - treat each frame as "refetch", not as a lossless log.
for await (const frame of duraton.runs.watchAll()) {
  console.log(frame.runId, "->", frame.status);
}

Control

// Each returns the affected run; replay, retryFromStep, and fork return the NEW run.
await duraton.runs.cancel("01HXYZ...");
await duraton.runs.pause("01HXYZ...");
await duraton.runs.resume("01HXYZ...");

const replayed = await duraton.runs.replay("01HXYZ...");                       // or replay(id, editedInput)
const resumed = await duraton.runs.retryFromStep("01HXYZ...", "triage");       // fork from a step
const bulk = await duraton.runs.bulkReplay({ status: "failed", since: "2026-06-01T00:00:00Z" });

// The audit log of control actions (newest first), optionally filtered.
const history = await duraton.runs.controlActions({ runId: "01HXYZ..." });

runs.fork, runs.score, and runs.scores are the eval surface - see Evals.

timeseries

Run counts and latency bucketed over time - the numbers behind the console's Overview charts.

const series = await duraton.runs.timeseries({
  app: "support-app",
  since: "2026-07-01T00:00:00Z",
  bucket: 3600,   // bucket width in seconds
});

series.bucketSeconds;
for (const b of series.buckets) {
  console.log(b.ts, b.total, b.counts.failed, b.avgMs, b.maxMs);
}

Prop

Type

Each bucket carries ts, per-status counts, a total, and avgMs / maxMs over the bucket's terminal runs - both absent when the bucket has none.

flowState

The live buffer state of the flow controls: how many events are currently coalescing in a debounce window, and how many are buffered toward a batch flush.

const state = await duraton.flowState({ app: "support-app" });

for (const d of state.debounce) console.log(d.workflow, d.pending, d.nextFireAt);
for (const b of state.batch) console.log(b.workflow, b.buffered, b.oldestAt);

In-flight and queued counts are not here - those come from runs.stats.

sessions, ai, approvals, datasets

// AI conversations: runs grouped by session id, most recent first.
const sessions = await duraton.sessions.list({ app: "support", limit: 20 });

// AI spend rolled up by hour, model, and workflow.
const spend = await duraton.ai.spend({ app: "support", since: "2026-07-01T00:00:00Z" });

// Decide an open human-in-the-loop gate, which resumes its parked run.
const open = await duraton.approvals.list({ status: "pending" });
await duraton.approvals.decide(open[0].id, { status: "approved", decidedBy: "[email protected]" });

// Datasets and eval run-sets - see Evals.
const ds = await duraton.datasets.create("qa-cases");

workflows, apps, runners, health

await duraton.workflows.list();   // registered workflows, with their triggers and flow control

// Start one off-schedule run of a workflow, by identity - works even for a cron-only
// workflow. See "Trigger a run manually" in the triggers guide.
const res = await duraton.workflows.trigger("support-app", "nightly-report", { input: { force: true } });
res.runId; // absent if a flow-control gate short-circuited it

await duraton.apps.list();        // registered apps
await duraton.runners.list();     // registered runners and their liveness
await duraton.health();           // { status: "ok" }
await duraton.ready();            // { status, checks, draining }

Errors

Any non-2xx response throws a DuratonApiError carrying the status and the raw body, with helpers so you branch on intent instead of on status numbers.

import { DuratonApiError } from "@duraton/sdk/client";

try {
  await duraton.runs.cancel(id);
} catch (err) {
  if (err instanceof DuratonApiError && err.isConflict()) {
    // 409: the run is already terminal
  } else if (err instanceof DuratonApiError && err.isNotFound()) {
    // 404: no such run
  } else {
    throw err;
  }
}

isBadRequest() (400), isUnauthorized() (401), isForbidden() (403), isNotFound() (404), and isConflict() (409) map to Duraton's status codes.

Runtime

The client is built on the global fetch and runs on any fetch-native runtime (Node 18+, Bun, Deno, Cloudflare Workers, browsers). The streaming methods - events.stream, runs.watch, runs.watchAll - additionally need a streaming fetch body, which all of those provide. No dependencies.

const duraton = createClient({
  url: process.env.DURATON_URL!,
  apiKey: process.env.DURATON_API_KEY,
  fetch: myInstrumentedFetch,   // any fetch-compatible implementation
});

On this page