Evals

Tell whether a prompt change made the agent better: score a run inline, grade it after it finishes, fork it with one change, or fan a dataset through it.

An eval answers one question: did this change make the output better? Duraton answers it with scores - named results attached to a run - and three ways to produce them: inline as the workflow runs, deferred by a grader workflow after it finishes, or in bulk by re-running a dataset.

An eval set's scores in the console
await ctx.score({ name: "helpfulness", value: 0.9, comment: "clear and correct" });
MechanismGradesUse it when
ctx.scorethe current runThe workflow can judge its own output as it goes.
ctx.assert.*the current runThe check is deterministic: output match, a step ran, steps ran in order.
Deferred scorersany finished runThe grader is separate from the graded workflow (an LLM judge, a human review).
Fork-compareone run vs its forkYou want to try one model or prompt change against a real run.
Dataset evalsa whole run-setYou want a number over many cases, not one.

The full option and response tables are in the evals SDK reference and the evals API reference.

Score inline

ctx.score records a named score on the current run. It is durable and memoized - a replay after a crash never records it twice - and the score name is its identity, so you pass no step id.

export const draftReply = defineWorkflow<{ ticket: string }>({
  name: "draft-reply",
  handler: async (ctx) => {
    const reply = await ctx.step.ai.generate("draft", {
      model: "claude-opus-4-8",
      prompt: `Reply to: ${ctx.event.data.ticket}`,
    });

    await ctx.score({ name: "length-ok", value: reply.text.length <= 500 ? 1 : 0, dataType: "boolean" });
    await ctx.score({ name: "tone", stringValue: "friendly", dataType: "categorical" });

    return reply.text;
  },
});

A score is numeric (a number in value), boolean (value is 0 or 1), or categorical (a label in stringValue). Read them back with duraton.runs.scores(runId), or filter the runs list to a named score in a value range: duraton.runs.list({ scoreName: "length-ok", minScore: 1 }).

Assert deterministically

ctx.assert.* are deterministic checks that each record a boolean score (source: "code") and return whether they passed. They read the run's own step record, so they need no model.

await ctx.assert.usedStep("retrieve");            // scores boolean "used:retrieve"
await ctx.assert.sequence(["retrieve", "answer"]); // scores boolean "sequence"
await ctx.assert.equals(answer.text, { expected: "Paris" }); // scores boolean "match"
AssertionRecordsChecks
usedStep(step)used:<step>That a step with that id ran - tool choice: did the agent take that path.
sequence(steps)sequenceThat the named steps ran in that relative order.
equals(actual, opts?)matchThat actual deep-equals opts.expected, defaulting to the eval item's expected output (see dataset evals).

Each takes { name?, comment? } to override the score name or annotate it.

Grade a finished run

A deferred scorer grades a run after it finishes, with no code in the graded workflow. When any run reaches a terminal state, Duraton emits the reserved system event duraton/run.finished. A workflow subscribed to it is an ordinary run that scores the finished run by passing its runId to ctx.score.

import { defineWorkflow, RUN_FINISHED_EVENT, type RunFinishedEvent } from "@duraton/sdk";

export const gradeHelpfulness = defineWorkflow<RunFinishedEvent>({
  name: "grade-helpfulness",
  triggers: [{ event: RUN_FINISHED_EVENT, if: "event.data.workflow == 'draft-reply'" }],
  handler: async (ctx) => {
    const finished = ctx.event.data;

    const judgment = await ctx.step.ai.generate<{ score: number }>("judge", {
      model: "claude-opus-4-8",
      prompt: `Rate the helpfulness 0..1 of: ${JSON.stringify(finished.output)}`,
      output: { type: "object", properties: { score: { type: "number" } }, required: ["score"] },
    });

    await ctx.score({
      runId: finished.runId,          // the FINISHED run, not this one
      name: "helpfulness",
      value: judgment.output?.score ?? 0,
      source: "llm-judge",
    });
  },
});

A scorer must filter which runs it grades. The if above is what keeps the loop from feeding itself: an unfiltered scorer would grade every finished run, including its own.

The duraton/run.finished payload carries the finished run's runId, workflow, app, status, and its output (or error). To grade by hand instead - a human review - post the score directly: duraton.runs.score(runId, { name: "human-review", value: 1, source: "human" }).

Fork-compare

A fork re-runs a finished run with one declared change on a single AI step - a different model, prompt, or params - so you can compare the outcome against the original. Duraton replays the steps before the changed step from their recorded results and resumes execution at the change.

const fork = await duraton.runs.fork(baseRunId, {
  step: "draft",              // the AI step to change; execution resumes here
  model: "claude-haiku-4-5",  // ...try a cheaper model
  // prompt: "Be more concise.",
  // params: { temperature: 0.2 },
});

const [base, candidate] = await Promise.all([
  duraton.runs.scores(baseRunId),
  duraton.runs.scores(fork.id),
]);

The fork is a run like any other, linked back to the base by replayOf and carrying its change in fork. It is a shadow run: its side effects render but do not deliver, so ctx.webhook.send, ctx.step.emit, and ctx.step.runWorkflow appear as steps without firing. The console's compare view renders the same diff, with a verdict strip of the base-vs-fork score deltas.

Shadowing covers Duraton's own side effects only. Arbitrary I/O inside a step.run body - a direct database write, say - is invisible to Duraton and is not shadowed. Fork the workflows whose outward effects go through Duraton.

Dataset evals

A dataset is a named collection of test cases, each an input to run the workflow against and an optional expected output to grade it on. Fanning a dataset through a workflow creates an eval run-set: one shadow run per item, graded and aggregated together.

const ds = await duraton.datasets.create("qa-cases");
await duraton.datasets.addItem(ds.id, { input: { q: "capital of france" }, expected: "Paris" });
await duraton.datasets.addItem(ds.id, { input: { q: "2+2" }, expected: "4" });

const { evalSet, runIds } = await duraton.datasets.runEval(ds.id, {
  workflow: "qa.answer",
  label: "baseline",
});

Inside the handler, an eval run knows what it is: ctx.eval is set only on an eval run and carries the item's expected output, so the workflow can grade itself against it.

export const qaAnswer = defineWorkflow<{ q: string }>({
  name: "qa.answer",
  handler: async (ctx) => {
    const answer = await ctx.step.ai.generate("answer", {
      model: "claude-opus-4-8",
      prompt: `Question: ${ctx.event.data.q}`,
    });

    if (ctx.eval) {
      await ctx.assert.equals(answer.text); // grades against ctx.eval.expected
      await ctx.assert.usedStep("answer");
    }

    return answer.text;
  },
});

Read the run-set's aggregate to compare a candidate against the baseline:

const sets = await duraton.datasets.evalRuns(ds.id);
// each set: { label, runCount, scores: [{ name, avg, count }] }

Change the model or the prompt, run the dataset again with label: "candidate", and the two sets' per-name averages are the comparison. The console's datasets view lists a dataset's eval sets with those averages and compares the two newest.

One fan-out evaluates at most 200 items (the oldest non-archived items up to that many). An item's expected rides ctx.eval.expected and never the run input, so grading a workflow never puts the answer in its own prompt.

Over MCP

An agent is a first-class user of all of this: score_run and list_scores record and read scores, fork_compare runs the A/B, and create_dataset + add_dataset_item + run_eval + list_eval_runs author a dataset, fan it through a workflow, and read the verdict. See the MCP reference.

On this page