# Agents and tools Source: https://docs.duraton.ai/agent-kit/agents-and-tools The two building blocks of @duraton/agent-kit: agent() declares the model, instructions and tools; tool() declares one callable the model may use, with its schema, behaviour hints and context bounds. ## `agent` `agent(ctx, id, options)` takes the workflow context, a stable step id, and: The model each turn calls. The task. Every turn after the first sees it again together with the tool results so far. Hard cap on turns. Required, not defaulted: how long an agent may keep calling tools is your decision. The system prompt - who the agent is and how it should behave. The tools the model may call, each from tool(). External MCP servers whose tools join the ones above. Each server is discovered once as a durable step. The gate for every tool that has not answered for itself. See Which rule applies to a tool below. Ceiling on the human decisions this agent may ask for. Omitted, there is none. See Capping the decisions an agent asks for below. A JSON Schema the final answer must satisfy. Set, the answer comes back parsed as the object rather than as text. See Answering in a shape below. How a turn is composed. Defaults to function-calling. A provider name resolves through the built-in registry; pass an AIProvider to run against your own adapter. Passed per call and never stored. Omit it to use the provider SDK's conventional env var. Per-turn output cap, passed to the provider. Passed to the provider when set. Stream each turn's tokens onto the run timeline as they arrive. Opt-in; the turn's durable result is unchanged either way. See [Streaming agent turns](/ai/streaming#streaming-agent-turns). Ask the provider to bill this agent's repeated prefix at its own cached rate. "conversation" is the scope an agent wants: every turn re-sends the instructions, the tool declarations and every exchange so far. Opt-in, and refused rather than silently downgraded when the provider cannot place a cache breakpoint. See [Prompt caching](/reference/sdk/ai-steps#prompt-caching). Named ceilings checked after each turn, in order; the first met ends the run and names itself. See Stopping an agent below. Optional early stop once a turn's tools have run; must be pure for replay. Prefer stopWhen. It returns the loop's result: `final` (the model's answer), `iterations`, `stopReason` (`"final"`, `"max-iterations"`, `"stopped"`, `"approval-budget"`, `"bail"` or `"guardrail"`), `stoppedBy` - which named condition ended it, when one did - and `haltedBy`, which names the guardrail on `"guardrail"`. See [Guardrails](/ai/guardrails). ## Stopping an agent `maxIterations` bounds turns. It does not bound time, and it does not bound the work one turn generates: a single turn calling twenty tools in parallel is one iteration and twenty-one steps. `stopWhen` covers the rest. | Condition | Ends the run when | Config | | ----------------------- | ------------------------------------------ | -------------------------------------------------------------------------- | | `wallClock({ within })` | the loop has been running that long | a duration string (`"5m"`) or milliseconds | | `maxSteps({ steps })` | the loop has spent that many durable steps | turns, tool calls, guardrail passes, approvals and trims all count | | `hasToolCall({ tool })` | the completed turn called that tool | whether it succeeded, was denied or was refused - the agent reached for it | ```ts theme={null} import { hasToolCall, maxSteps, wallClock } from "@duraton/sdk"; const result = await agent(ctx, "triage", { model: "claude-opus-5", prompt: ticket.body, maxIterations: 8, stopWhen: [wallClock({ within: "5m" }), maxSteps({ steps: 20 }), hasToolCall({ tool: "escalate" })], }); result.stopReason; // "stopped" result.stoppedBy; // "wallClock" ``` Conditions are asked in the order you list them and the first met wins, so the list reads as a priority order. Each pass is its own durable step, which is what lets `wallClock` read a real clock and still replay to the same decision - a replayed run re-walks its committed turns in milliseconds, and a re-read clock would end it somewhere else. Two other things follow from being data rather than a closure: a condition registers into the workflow's agent manifest, so the console can show what bounds an agent before it has ever run; and `stoppedBy` names the one that fired, on the result and on the run's trace. The `stop` closure still works and is still the right tool for a predicate nobody needs to read back - it reports `stopReason: "stopped"` with no `stoppedBy`, because a closure has no name. ### Ending early on success A tool that has already produced the answer says so with `bail()`, and the loop returns that value instead of handing the result back for a turn it does not need. ```ts theme={null} import { bail } from "@duraton/sdk"; tool({ name: "lookup_order", handler: async (input) => { const order = await orders.find(input.id); return order.refunded ? bail({ status: "already refunded" }) : order; }, }); ``` The run ends with `stopReason: "bail"` and the value as `final`. Every other tool the same turn started still runs to completion first - a side effect that already happened is not dropped from the record because a sibling finished sooner. `bail()` is returned, never thrown. A tool handler runs inside a durable step, so a throw is committed as that step's failure - an early success would be indistinguishable from a broken tool, and would replay as a failure forever. ## `tool` A tool is one definition. Give it a name the model calls back with, a description it reads to decide, and a JSON Schema for the input it must produce: The tool's identity: what the model calls back with, and the tail of the durable step name its call writes. Must be unique within an agent. What the tool does. This is what the model reads to decide whether to call it. JSON Schema for the input the model must produce. A tool with none is declared as an object schema with no properties. JSON Schema for what the tool returns. Nothing validates against it - it is published, so a surface can render a result before it has seen one. Project the result for the model only. The durable step still records what the tool returned. See Showing the model less than you record below. Behaviour hints - title, readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Hints, never gates. See Behaviour hints below. Your own code. Runs as a durable step: the model's input in, the return value back to the model. A workflow to run instead of a handler. The tool call becomes a linked child run. The workflow tool's app; addressed like step.runWorkflow. Pin the workflow tool to a specific runner. Park the run on a human before this tool runs. See Tools a human has to approve below. Annotates the gate (risk, summary, policy, context, escalatesTo, allow, timeout, onTimeout) and decides whether it is raised at all (if, minRisk). Declaring one gates the tool; requiresApproval: false opts out of it. The integration this tool reaches, e.g. "google-calendar" - a free-text label, not a Credential id. Feeds the tools manifest below. Exactly one of `handler` or `workflow` - a tool is backed by your code or by a workflow: ```ts theme={null} const lookupOrder = tool({ name: "lookup-order", description: "Fetch an order by id", inputSchema: { type: "object", properties: { orderId: { type: "string" } } }, workflow: "orders.lookup", app: "orders", }); ``` That call is followable in both directions (call->run lineage). The tool's own durable step carries `childRunId` - the id of the run it started - and the run it started carries `parentRunId`, `parentStep` (the step it was spawned on) and `parentAttempt` (which attempt of that step spawned it). So `GET /runs?parentRunId=` returns every child one agent run started, and each child links back to the exact call that made it. A handler-backed tool has neither end of the edge: it runs inside the agent's own run. See the [runs API](/reference/api/runs). A tool's `input` is raw model output. The schema tells the model what to produce; it does not validate what arrives, so parse it in the handler before you trust it. ## Behaviour hints `annotations` describes how a tool behaves, in the same field names MCP uses, so one declaration means the same thing to Duraton and to any MCP client reading your tools: ```ts theme={null} const dropIndex = tool({ name: "drop-index", description: "Drop a database index", annotations: { title: "Drop index", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false, }, requiresApproval: true, handler: (input) => db.dropIndex(input.name), }); ``` | Hint | Says | | ----------------- | ------------------------------------------------------------------- | | `title` | A human-readable name for the tool, for a picker or a form label | | `readOnlyHint` | The tool does not change anything | | `destructiveHint` | The tool can destroy or overwrite something | | `idempotentHint` | Calling it twice with the same input is the same as calling it once | | `openWorldHint` | The tool reaches something outside your system | These are hints, and a hint is not a gate. `destructiveHint: true` on its own stops nothing - `requiresApproval` and its [rule](/agent-kit/approvals#gating-some-calls-and-not-others) are what park the run on a person, and a hint is not one of the four names a rule can read. The hints exist so a surface can suggest the right default without every dangerous tool having to be marked by hand, and MCP defines them the same way: a client is told never to make tool-use decisions on annotations it received from a server it does not trust - on an [attached server](/agent-kit/providers-and-mcp#attaching-an-external-mcp-server) the hint is written by that server's operator, not by you. Set both on a tool that is genuinely destructive. ## What the model sees Every turn sees the task, the exchanges so far, and your tools declared as function-calling schemas. How the exchanges reach the model depends on the adapter: | Adapter | What it gets | | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Declares the `transcript` capability (the built-in Anthropic one does) | The task as the prompt, and the turns as structured `transcript` - which the adapter maps to its provider's native tool blocks | | Declares nothing | The task with the turns rendered into it as text, exactly as before | The fallback is not a lesser answer, just a lesser shape: a model reading its own tool calls as prose is working in a form it was not trained on. Either way the turns are read from the loop's recorded history and nothing else, so a replayed turn is identical to the original one. ## Bounding context Nothing trims history by default, so an agent that runs long enough eventually hits the model's context window and fails. Pass `context` and every turn - both a native `transcript` and the rendered-text fallback above - sees a bounded view instead of the full, ever-growing history: ```ts theme={null} const result = await agent(ctx, "support", { model: "claude-opus-4-8", prompt: `Resolve this ticket: ${ticket}`, tools: [searchPolicy, issueRefund], maxIterations: 20, context: { strategy: "count", keepLast: 10 }, }); ``` | `strategy` | What it does to the record | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `count` | Drops the oldest iterations beyond `keepLast`. A fresh view computed for each turn - never written back, so the run's own history still has everything | | `token-budget` | Same drop, sized by `maxTokens` instead of a count - an approximation (`estimateTokens`), not a real tokenizer | | `summarize` | Once history exceeds `maxTokens`, overwrites the earliest surviving iteration's own result with a model-generated summary of everything older than `keepLast`, and drops the rest. Reuses that iteration's real id rather than inventing one - see [Context management](/ai/context-management) for why | `count` and `token-budget` are free: recomputed fresh every turn, they never change what the run itself remembers. `summarize` is the one that costs something, so it is the one that persists - a later turn starts from the already-reduced history instead of paying to re-summarize the same prefix every time. `summarize`'s model call rides its own durable step, so a replay reads the recorded summary rather than generating a new one - full detail on that step and its replay guarantee lives on the [Context management](/ai/context-management) page, the SDK-level page for the loop-level `context` option this wraps. Without `context`, an agent behaves exactly as it did before the option existed - the whole history reaches every turn. The option changes nothing for an agent that does not use it. # Approvals in the loop Source: https://docs.duraton.ai/agent-kit/approvals Gate a tool call on a human decision, cap how many decisions an agent may ask for, show the model less than you record, and check the arguments it produced before they run. ## Tools a human has to approve Mark the tools an agent should not use unattended. The run parks on a reviewer before the tool runs, holding no worker while it waits: ```ts theme={null} const issueRefund = tool({ name: "issue-refund", description: "Issue a refund to the customer", inputSchema: { type: "object", properties: { amount: { type: "number" } } }, requiresApproval: true, handler: (input) => refunds.issue(input), }); ``` `requiresApproval: true` raises the gate and says nothing else about it. **Say more with `approval`**, and the same annotations a hand-written [`ctx.step.approval`](/ai/approvals) takes apply to the tool's gate: ```ts theme={null} const issueRefund = tool({ name: "issue-refund", requiresApproval: true, approval: { risk: "high", summary: "Refund a customer for a duplicate charge", allow: ["approve", "reject"], timeout: "30m", onTimeout: "reject", }, handler: (input) => refunds.issue(input), }); ``` **Declare a `risk` on anything you would not want cleared by a credential.** A gate that states no risk is stored at the default, `medium`, and the [human-decision floor](/ai/approvals#who-may-decide-what) reads the stored risk - so a floor of `high` cannot refuse a gate that never claimed to be high. The reviewer sees the input the model proposed and can change it before approving - the tool then runs with **their** input, not the model's. A denial is not an error: it comes back to the model as that tool's result, so the agent can say what it could not do instead of the run failing. | Decision | What runs | What the model gets | | ------------------- | -------------------------------------- | -------------------------------------- | | Approved | The handler, with the decided input | The handler's return value | | Approved with edits | The handler, with the reviewer's input | The handler's return value | | Denied | Nothing | `{ approved: false, tool, decidedBy }` | Each gate is its own durable step, so resuming the run replays the agent without asking anyone twice. Both the approval and the tool call show up under their turn in the console, so a parked agent reads as parked rather than as a tool that never finished. ### Gating some calls and not others `requiresApproval` is one bit for the whole tool, so a `refund` tool marked `true` wakes someone for a $2 refund and one marked `false` wakes nobody for a $50,000 one. `approval.if` and `approval.minRisk` are the third answer: a condition on **this call**, written as data and evaluated by Duraton. ```ts theme={null} const issueRefund = tool({ name: "issue-refund", inputSchema: { type: "object", properties: { amount: { type: "number" } } }, requiresApproval: true, approval: { risk: "high", // Small refunds go through; anything above the limit waits for a person. And after // three turns the agent is looping, so gate whatever it reaches for next. if: "args.amount > 1000.0 || iteration > 3", }, handler: (input) => refunds.issue(input), }); ``` The expression reads four names and no others - `tool`, `args`, `risk` and `iteration` - and every JSON number in `args` reaches it as a double. A rule that cannot be evaluated raises the gate rather than skipping it, and one that declines completes the step as `decidedBy: "system:rule"` with no approval for anyone to answer. The full environment, the failure modes and when this beats a hand-written `if` are on [Approvals](/ai/approvals#deciding-whether-to-gate-at-all). ### Which rule applies to a tool A rule can be declared in two places: on the tool, and on the agent as the default for every tool that has not answered for itself. ```ts theme={null} await agent(ctx, "support", { model: "claude-opus-4-8", prompt: `Resolve this ticket: ${ctx.event.data.ticket}`, tools: [searchPolicy, issueRefund], mcpServers: [{ name: "orders", transport: { kind: "http", url: process.env.ORDERS_MCP_URL } }], // Anything that did not answer for itself gates, and gates like this. approval: { risk: "high", summary: "A tool this agent was not told how to treat", timeout: "1h" }, maxIterations: 6, }); ``` The two levels resolve in one order, and `requiresApproval` is **tri-state** on purpose - absent, `true` and `false` are three different answers rather than a boolean with a default: | The tool says | The agent has a default | The gate | | ------------------------- | ----------------------- | ----------------------------------------------------------- | | `requiresApproval: false` | either way | **none.** An opt-out is an answer, and it beats the default | | `approval: { ... }` | either way | raised, under the **tool's own** rule | | `requiresApproval: true` | yes | raised, under the **agent's default** | | `requiresApproval: true` | no | raised, with nothing said about it | | nothing | yes | raised, under the **agent's default** | | nothing | no | none | Two rows are worth reading twice. `requiresApproval: true` on a tool the agent has a default for takes that default's annotations rather than erasing them: `true` asks for a gate, it does not claim there is nothing to say about one. And a tool's own `approval` **replaces** the default rather than merging with it - a rule is one policy, so a tool that states `risk` and no `timeout` has no timeout, whatever the default said. A tool with **no opinion at all** inherits the default. That is the point: `requiresApproval: false` is how a tool says it never waits for anyone, and saying nothing is not the same as saying that. The default is also what covers an [MCP server's tools](/agent-kit/providers-and-mcp#attaching-an-external-mcp-server) that the server's own predicate says nothing about. Those tools are attached here and discovered at runtime, so there is no declaration above to annotate and no list to enumerate ahead of time. ## Capping the decisions an agent asks for `maxApprovals` is a ceiling on how many human decisions one agent may ask for. When a turn's tool calls would take it past the ceiling, the agent stops instead of running them, and returns `stopReason: "approval-budget"`: ```ts theme={null} const result = await agent(ctx, "support", { model: "claude-opus-4-8", prompt: `Resolve this ticket: ${ctx.event.data.ticket}`, tools: [searchPolicy, issueRefund], maxIterations: 12, maxApprovals: 3, }); if (result.stopReason === "approval-budget") { return { outcome: "handed-off", reason: "the agent asked for more sign-off than it was allowed" }; } ``` Omitted, there is no ceiling. Set one where an agent could plausibly keep reaching for a gated tool: `maxIterations` bounds how long it runs, and this bounds how much of a person's attention it can spend doing so. Handle the stop like any other terminal reason - the agent returns rather than throwing, so the workflow decides what a run that ran out of sign-off does next. ## Showing the model less than you record A tool that returns a thousand rows costs a thousand rows of context on every turn after it. Give it a `toModelOutput` and the model reads the summary while the durable step keeps the whole thing: ```ts theme={null} const listOrders = tool({ name: "list-orders", description: "Every order for a customer", outputSchema: { type: "object", properties: { orders: { type: "array" } } }, toModelOutput: (output) => ({ count: output.orders.length, ids: output.orders.slice(0, 5).map((o) => o.id), }), handler: (input) => orders.listFor(input.customerId), }); ``` The projection is not a truncation you can never undo. The tool's step still holds all thousand orders, so the run's history, a replay, and anything reading the timeline all see the full result - only the model's context was spent on the summary. `toModelOutput` must be pure. A replay re-projects the result the tool already returned rather than calling the tool again, so a projection that reads the clock or a counter makes a replayed turn disagree with the original. It runs only on a result your tool actually produced. A denial from an approval gate is the loop telling the model about its own gate, so it reaches the model unprojected. ## Checking the model's tool arguments The arguments a tool is called with are written by the model. Pass a guardrail and they are checked against the tool's own `inputSchema` before the handler ever sees them; a refusal comes back to the model as that tool's result, so it can correct itself. ```ts theme={null} import { createSchemaGuardrail } from "@duraton/sdk"; const result = await agent(ctx, "agent", { model: "claude-opus-4-8", prompt: `Resolve this ticket: ${ctx.event.data.ticket}`, tools: [searchPolicy], maxIterations: 6, guardrails: [createSchemaGuardrail()], }); ``` The verdict is its own durable step, so a replay reads what was decided instead of deciding again. [Guardrails](/ai/guardrails) covers the placements, the actions, and how to write your own. # Agent kit Source: https://docs.duraton.ai/agent-kit/index Write an AI agent as a durable workflow: agent() and tool() from @duraton/agent-kit, where every model turn and tool call is a step that survives a crash. This page is for the code path - building or extending an agent by hand. To build one without code, start with [Build your first agent](/start/first-agent). `@duraton/agent-kit` is the authoring layer on top of [`step.ai.loop`](/reference/sdk/ai-steps#step-ai-loop). You declare a model, some instructions and some tools; the kit composes the model call for each turn. Everything durable stays where it already was - one step per turn, one per tool call, reused on replay - so an agent written with the kit is an ordinary durable run. ```sh theme={null} npm install @duraton/agent-kit ``` ## A first agent ```ts theme={null} import { agent, tool } from "@duraton/agent-kit"; import { workflow } from "@duraton/sdk"; const searchPolicy = tool({ name: "search-policy", description: "Look up the refund policy that applies to a ticket", inputSchema: { type: "object", properties: { topic: { type: "string" } }, required: ["topic"], }, handler: (input) => policyIndex.find(parseTopic(input)), }); export const support = workflow<{ ticket: string }>({ name: "support.agent", handler: async (ctx) => { const result = await agent(ctx, "agent", { model: "claude-opus-4-8", instructions: "You are a support agent. Check the refund policy before answering.", prompt: `Resolve this ticket: ${ctx.event.data.ticket}`, tools: [searchPolicy], maxIterations: 6, }); return { answer: result.final, iterations: result.iterations }; }, }); ``` That run records `agent:iter:0`, `agent:iter:0:tool:`, `agent:iter:1`, and so on - the same step names a hand-written loop writes, which is why an agent needs no special handling to show up in the console as an agent. ## What is in the kit `agent()` and `tool()`: model, instructions, tools, behaviour hints, and what the model sees. Gate a tool call on a person, cap the decisions an agent may ask for, check its arguments. Answer in a declared shape, validated and recorded as a durable step. Bring your own provider, attach an external MCP server, expose your tools over MCP. ## The kit adds no loop `agent()` holds no iteration counter, no history and no retry logic. `step.ai.loop` already owns all three, and its `turn` is the extension point the kit fills. That is what keeps an agent resumable after a crash, free of repeated model calls on replay, and visible in the console with nothing extra to configure. # Providers and MCP Source: https://docs.duraton.ai/agent-kit/providers-and-mcp Bring your own model provider, attach an external MCP server as a tool source, list tools before they run, and expose the same tools over MCP to other agents. ## Bring your own provider `provider` takes an `AIProvider` instance as well as a name, which is how an agent runs against an adapter you wrote - or against a deterministic stand-in in tests, with no API key: ```ts theme={null} await agent(ctx, "agent", { model: "demo-1", prompt: "Resolve the ticket", tools: [searchPolicy], maxIterations: 4, provider: myProvider, }); ``` See [Providers](/reference/sdk/ai-steps#providers) for the `AIProvider` contract, including how a tool declaration reaches the model and how tool calls come back. ## Attaching an external MCP server `mcpServers` gives an agent tools you did not write. The kit connects to the server, reads its tool list, and hands those tools to the model alongside your own - each call running as an ordinary durable step. ```ts theme={null} const result = await agent(ctx, "support", { model: "claude-opus-4-8", prompt: `Resolve this ticket: ${ctx.event.data.ticket}`, tools: [searchPolicy], mcpServers: [ { name: "orders", transport: { kind: "http", url: process.env.ORDERS_MCP_URL }, tools: ["lookup-order"], requiresApproval: (tool) => tool.name === "cancel-order", }, ], maxIterations: 6, }); ``` Namespaces this server's tools. "orders" turns the remote "lookup-order" into "orders\_\_lookup-order", so two servers offering the same tool stay distinguishable. How to reach the server. \{ kind: "http", url } speaks Streamable HTTP. Sent with every request. The function form runs inside the durable step, which is where credentials.resolve() is legal. Remote tool names to attach, unprefixed. Omitted, the agent gets everything the server offers. Which of this server's tools park on a human first. A predicate, because you did not author these tools and cannot annotate them one by one. Return a rule to say how the gate is raised as well as whether; return nothing for a tool and it falls to the agent's own approval default. The predicate answers per tool, and what it returns places that tool in the [same two levels](/agent-kit/approvals#which-rule-applies-to-a-tool) a declared tool goes through: | It returns | The gate | | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | an `ApprovalRule` | raised, under that rule. It sets the rule and leaves `requiresApproval` unset, so an explicit `false` stays the only opt-out | | `true` | raised, under the agent's `approval` default when there is one | | `false` | none, whatever the default says | | nothing, or no predicate at all | the agent's `approval` default | The last row is why the default exists. These are the tools you did not write, attached here and discovered at runtime, so there is no declaration to annotate and no list to enumerate ahead of time. Silence about a tool you did not write is not a decision that it is harmless. Install the MCP SDK alongside the kit - it is an optional peer, so an agent that attaches no server never pulls it: ```bash theme={null} bun add @modelcontextprotocol/sdk ``` ### What it records | Step | Written when | Holds | | ------------------- | --------------------------- | -------------------------------- | | Tool discovery | once, before the first turn | the tool list the server offered | | One per remote call | each remote call | the call's result, memoized | Discovery is a durable step because the tool list decides what the model was offered. A server that adds or drops a tool mid-run cannot change what a replay sees, and a fully replayed run opens no connection at all. Name the tools you want. An allow-list is what stops a server you do not control from widening your agent's reach by adding a tool - and asking for one the server does not offer fails the run rather than quietly attaching fewer tools than you asked for. ### Credentials, approval and bad arguments A remote tool is still a tool, so everything the kit already does applies to it: * **Credentials** resolve inside the step, so a stored credential works: ```ts theme={null} headers: async () => { const orders = await client.credentials.resolve("orders"); return { Authorization: `Bearer ${orders.token}` }; }, ``` * **Approval** parks the run before the call, and a denial comes back to the model as that tool's result. A `destructiveHint` in the server's own annotations never gates on its own - hints inform a default, `requiresApproval` is the boundary. * **Guardrails** validate the model's arguments against the *server's* `inputSchema`, so a schema you did not write still stops a malformed call reaching a third party. A remote tool that answers with MCP's `isError` **fails its step** rather than returning the error text to the model. The step then retries under your workflow's policy and the run records why; memoizing a broken call as a successful result would leave no replay able to get past it. ## Listing tools before they run By default the engine has no record of a tool until a run calls it. `toolManifest()` projects your `tools` and `mcpServers` arrays into `workflow`'s advisory `tools` field, so a tool - its name, description, and parameter schema - is listable before it has ever executed: ```ts theme={null} import { agent, tool, toolManifest } from "@duraton/agent-kit"; const tools = [searchPolicy, issueRefund]; const mcpServers = [{ name: "orders", transport: { kind: "http", url: process.env.ORDERS_MCP_URL } }]; export default workflow({ name: "support.ticket", tools: toolManifest({ tools, mcpServers }), handler: (ctx) => agent(ctx, "support", { model: "claude-opus-4-8", prompt: "Resolve the ticket", tools, mcpServers }), }); ``` One array, two readers - `toolManifest()` reads the same `tools`/`mcpServers` you pass to `agent()`, so the manifest can never silently disagree with what the model actually sees. An attached MCP server appears as an unexpanded group (its name only) rather than a tool list: its tools are discovered at run time, not registration time, so listing them here would mean contacting the server before a run even starts - reintroducing the replay drift durable discovery exists to prevent. Full field reference and the advisory guarantees are in [Tool manifest](/reference/sdk/defining-workflows#tool-manifest-optional). ## Exposing the same tools over MCP A tool is one definition, and the kit emits it in whichever dialect a surface needs. `agent()` uses the function-calling dialect; `createMcpEmitter()` produces the same tools as [Model Context Protocol](https://modelcontextprotocol.io) definitions, so the tools your agent uses are the tools an MCP client sees - one source, not two lists that drift. ```ts theme={null} import { createMcpEmitter } from "@duraton/agent-kit"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; const tools = [searchPolicy, lookupOrder]; server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: createMcpEmitter().emit(tools), })); ``` The emitter produces the definitions a server advertises - the name, description, input schema, and the `outputSchema` and `annotations` when a tool declares them. What a call then does is the server's decision, and stays yours to write. `requiresApproval` is never emitted. Approval is Duraton parking *your* run on *your* reviewer; it is not something a client on the other end of an MCP connection can honour, and advertising it would read as a promise the protocol cannot keep. Use the low-level `tools/list` handler as above rather than `registerTool`: `registerTool` takes a Zod schema for its input, while a kit tool declares a JSON Schema, which is what `tools/list` carries on the wire. ## Extension points Two seams, each a closed set of adapters. A new one is a new adapter, not a change to the existing ones. | Port | Decides | Adapters | | --------------- | -------------------------------------------------- | ------------------------- | | `AgentStrategy` | how a turn is composed | `function-calling` | | `ToolEmitter` | which dialect a tool is emitted in | `function-calling`, `mcp` | | `Guardrail` | what a tool call is checked against before it runs | `schema` | | `McpTransport` | how an attached MCP server is reached | `http` | # Structured output Source: https://docs.duraton.ai/agent-kit/structured-output Have an agent answer in a declared shape: a JSON schema the final answer must satisfy, validated and recorded as a durable step. ## Answering in a shape By default an agent's answer is the model's text. Pass `output` - a JSON Schema, the same raw-schema form `step.ai.generate` takes - and two things change: the model is constrained to that shape, and the answer comes back **parsed**, so `final` is the object rather than a string you have to parse yourself. ```ts theme={null} interface Invoice { total: number; currency: string; } const result = await agent(ctx, "billing", { model: "claude-opus-4-8", prompt: "Total this invoice and give me the currency.", maxIterations: 4, tools: [lineItems], output: { type: "object", properties: { total: { type: "number" }, currency: { type: "string" } }, required: ["total", "currency"], additionalProperties: false, }, }); result.final?.total; // a number, not a substring ``` The type parameter on `agent` is your assertion about the schema you passed - nothing checks the parsed value against the schema, exactly as with `step.ai.generate`. What `output` guarantees is that the answer is valid JSON and that the model was constrained while producing it. Two ways this fails, both loudly rather than by handing you the wrong thing: | Situation | What happens | | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The provider cannot constrain the model | The agent refuses before calling it, naming the provider. Only adapters declaring the `structured-output` capability are accepted - the Anthropic adapter does, the AI SDK adapter does not, because `generateText` takes no schema | | The model answers with text that is not JSON | The run fails. Returning the raw string would hand back a value typed as your shape that is not one | Without `output`, `agent()` behaves exactly as it always has and `final` is the model's text. The option changes nothing for an agent that does not use it. # AI agents Source: https://docs.duraton.ai/ai/ai-steps Build AI agents that survive a crash - step.ai turns model calls, tool calls, and agent loops into durable steps, plus the classic agent patterns. A model call is slow, expensive, and can fail halfway. Wrapping it in a durable step turns it into a checkpoint: the call happens once, its result is saved, and a crash or retry resumes from after it rather than paying for it again. That is all `step.ai` is - the [step API](/core/steps) for model calls, embeddings, and agent loops. See [AI steps](/reference/sdk/ai-steps) for the full reference, or the [AI quickstart](/start/ai-quickstart) to run your first one end to end. ## The building block The unit is a single augmented model call - an LLM with tools and structured output. In Duraton that is `step.ai.generate`: ```ts theme={null} const { output } = await ctx.step.ai.generate("classify", { model: "claude-opus-4-8", prompt: `Classify this ticket: ${subject}`, output: triageSchema, }); ``` The call is memoized under `"classify"`, so on replay the workflow skips it and reuses the saved answer. Composing calls means composing checkpoints: ```ts theme={null} export const triageTicket = workflow<{ subject: string }>({ name: "ticket.created", handler: async (ctx) => { const { output } = await ctx.step.ai.generate<{ category: string; priority: string }>("classify", { model: "claude-opus-4-8", prompt: `Classify this ticket: ${ctx.event.data.subject}`, output: { type: "object", properties: { category: { type: "string" }, priority: { type: "string" } }, required: ["category", "priority"], }, }); // A crash here replays the handler from the top: "classify" returns its recorded // answer without calling the model, and only "draft-reply" is actually charged. const { text } = await ctx.step.ai.generate("draft-reply", { model: "claude-opus-4-8", prompt: `Write a reply for a ${output?.priority} ${output?.category} ticket.`, }); return { category: output?.category, reply: text }; }, }); ``` ## The agent loop When the model should decide its own next step - call a tool, look at the result, call another - use `step.ai.loop`. Each turn is a durable step and each tool call is a durable step, so a long-running agent survives a restart and resumes at the last committed turn instead of starting over. ```ts theme={null} const agent = await ctx.step.ai.loop("agent", { prompt: `Resolve the ticket about: ${subject}`, maxIterations: 6, tools: { "search-kb": { handler: (q) => searchKb(q) }, "lookup-order": { workflow: "orders.lookup", app: "orders" }, }, turn: (ctx, i) => callModel(ctx.prompt, ctx.history, i), }); ``` A tool can be a local `handler` or another **workflow**. A workflow tool becomes a linked child run - a full durable run with its own retries and steps - so the agent can delegate real work, not just call a function. That linkage is what makes the orchestrator pattern below durable end to end. ## Agent patterns Anthropic's [Building effective agents](https://www.anthropic.com/engineering/building-effective-agents) distinguishes composable *workflows* (the model follows a fixed structure) from *agents* (the model directs itself). Each maps onto Duraton primitives, and because every model call is a step, each becomes crash-safe for free. The pattern names and definitions below are Anthropic's; the mapping to `step.ai` is the durable implementation. ### Prompt chaining Decompose a task into a fixed sequence of calls, each consuming the previous one's output. Every call is its own step, so a failure in the third link resumes from the saved output of the second. ```ts theme={null} const outline = await ctx.step.ai.generate("outline", { model, prompt: `Outline: ${topic}` }); const draft = await ctx.step.ai.generate("draft", { model, prompt: `Expand:\n${outline.text}` }); const final = await ctx.step.ai.generate("polish", { model, prompt: `Tighten:\n${draft.text}` }); ``` ### Routing Classify the input, then send it to a specialized follow-up. Classify with a structured `generate`, then branch to the workflow that handles that class: ```ts theme={null} const { output } = await ctx.step.ai.generate<{ queue: string }>("route", { model, prompt: `Which queue handles: ${subject}`, output: { type: "object", properties: { queue: { type: "string" } }, required: ["queue"] }, }); const result = await ctx.step.runWorkflow("handle", { name: `support.${output?.queue}` }); ``` ### Parallelization Run independent calls at once and aggregate the results (**sectioning**), or run the same call several times and combine the answers (**voting**). Fan out with `Promise.all` - each branch is its own durable step and the run joins when all have committed: ```ts theme={null} const [safety, topic, sentiment] = await Promise.all([ ctx.step.ai.generate("safety", { model, prompt: `Unsafe? ${text}` }), ctx.step.ai.generate("topic", { model, prompt: `Topic of: ${text}` }), ctx.step.ai.generate("sentiment", { model, prompt: `Sentiment of: ${text}` }), ]); ``` ### Orchestrator-workers A central model breaks a task down, delegates to workers, and synthesizes the results. This is `step.ai.loop` with **workflow tools**: the loop is the orchestrator, each workflow tool is a worker, and every delegation is a linked child run that retries and checkpoints on its own. ```ts theme={null} await ctx.step.ai.loop("orchestrate", { prompt: `Ship the change described in: ${brief}`, maxIterations: 12, tools: { "search-code": { workflow: "repo.search" }, "open-pr": { workflow: "repo.open-pr", app: "ci" }, }, turn: (ctx, i) => planNextStep(ctx.prompt, ctx.history, i), }); ``` ### Evaluator-optimizer One call generates, another evaluates and feeds back, in a loop until the check passes. For a single output against a schema, `generate`'s built-in [durable re-ask](/reference/sdk/ai-steps#structured-output-and-durable-re-ask) is this pattern - `validate` is the evaluator and each re-ask is a durable step: ```ts theme={null} const { output } = await ctx.step.ai.generate("summary", { model, prompt: `Summarize in under 40 words: ${doc}`, output: summarySchema, validate: (v) => (wordCount(v) <= 40 ? undefined : "too long, tighten it"), reask: 3, }); ``` For a free-form generate/critique cycle, run the two calls as steps inside `step.ai.loop` and `stop` when the evaluator is satisfied. ## Keeping it replay-safe The model results are memoized, but the code around them - your `turn`, `validate`, and `stop` functions, and any branching on a result - runs again on every replay. Keep them deterministic (a pure function of their inputs) so a resumed run takes the same path it took the first time. The [durable-execution](/core/durable-execution) rules for regular steps apply unchanged. # Approvals Source: https://docs.duraton.ai/ai/approvals Stop your agent before a risky action and wait for a person - the run parks holding no worker, then resumes from exactly that checkpoint. Some steps should not run until a person signs off - issuing a refund, deleting records, sending a bulk email. An **approval** is a step that parks the run on that decision: the run suspends in `needs_attention`, keeps its checkpoint, and holds no runner until someone (or an agent) approves or denies it. The decision resumes the run from exactly where it paused. An approval awaiting a human decision in the console ```ts theme={null} const decision = await ctx.step.approval("refund-gate", { tool: "issue-refund", args: { orderId, amount, currency: "usd", reason: "billing_error" }, risk: "high", summary: `Refund ${amount} to ${orderId} for a duplicate charge`, policy: "tools.issue-refund -> require approval", escalatesTo: "#support-leads", timeout: "30m", }); if (decision.status === "denied") return { outcome: "denied" }; // decision.args are the effective args - the decider's edits when changed, else the proposed ones. const refund = await ctx.step.run("issue-refund", () => issue(decision.args)); ``` This is the same durable-suspension machinery as [`waitForEvent`](/core/steps) and sleep - a parked run costs nothing while it waits and survives restarts - but what it waits on is a human decision rather than an event or a timer. ## The request `ctx.step.approval(id, request)` takes a stable step `id` and the request below. Only `tool` is required; the rest annotate the decision for whoever reviews it. | Property | Type | Default | Description | | ------------- | ----------------------------------------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tool` | `string` | required | The action awaiting sign-off, e.g. `"issue-refund"`. | | `args` | `A` | none | The proposed input for that action. The decider sees it and may edit it before approving. | | `risk` | `"low"` \| `"medium"` \| `"high"` | none | The declared risk level, shown on the approval in the inbox. | | `summary` | `string` | none | A one-line human description of what is being asked. | | `policy` | `string` | none | The rule that required an approval here, recorded on the request. | | `context` | `string` | none | Any further background for the reviewer. | | `allow` | `HitlDecision[]` | every decision | Which decisions this reviewer may take. `["approve", "reject"]` offers no edit affordance, and the engine refuses a verb the request did not offer. | | `escalatesTo` | `string` | none | The escalation target named on the approval once its timeout elapses. | | `timeout` | `string` \| `number` | no deadline | The deadline (`"30m"`, or ms). What reaching it does is `onTimeout`; with no timeout the approval waits indefinitely. | | `onTimeout` | `"escalate"` \| `"approve"` \| `"reject"` \| `"fail"` | `"escalate"` | What the deadline does to an undecided approval. Only `escalate` leaves it open - see [Timeouts](#timeouts-and-escalation). Ignored without a `timeout`: there is no deadline to reach. | | `if` | `string` | always gate | A CEL condition deciding whether the gate is raised at all - see [Deciding whether to gate at all](#deciding-whether-to-gate-at-all). | | `minRisk` | `"low"` \| `"medium"` \| `"high"` | no floor | A floor on this request's own `risk`: gate only at or above it. | | `iteration` | `number` | `0` | The agent loop turn this call was made on, readable from `if`. Set for you inside an agent; `0` on a hand-written gate. | ## Deciding whether to gate at all The rest of the request describes a gate that is already being raised. `if` and `minRisk` decide whether it is raised at all, and they are **data on the request**, not code around it. That is the difference between one bit per tool and a policy. A `refund` tool that always gates wakes someone for a $2 refund; one that never gates does not wake anyone for a $50,000 one. A rule is the third answer: ```ts theme={null} const decision = await ctx.step.approval("refund-gate", { tool: "issue-refund", args: { orderId, amount }, risk: "high", minRisk: "medium", if: "args.amount > 1000.0", }); ``` **`if` and `minRisk` together are a conjunction.** The gate is raised only when the request's risk clears the floor **and** the expression is true. Neither half overrides the other, and both are evaluated in the same place, so a run never records half a verdict. Inside an agent the same rule can be declared on the tool or on the agent as a default. Which of the two applies to a given call is on the [agent kit page](/agent-kit/approvals#which-rule-applies-to-a-tool); everything below holds either way. ### The four names A rule is evaluated by Duraton, in [CEL](https://cel.dev) - the same dialect as a [trigger `if`](/core/triggers), never a second language and never a closure in your runner. Its environment is exactly four names: | Name | Type | Is | | ----------- | -------- | -------------------------------------------------------------------------------------------------- | | `tool` | `string` | The action awaiting sign-off: the request's own `tool`. | | `args` | map | The proposed arguments: the request's own `args`. An empty map when the call proposed none. | | `risk` | `string` | The risk the approval will be stored at - the request's `risk`, or `"medium"` when it states none. | | `iteration` | `int` | The agent loop's turn this call was made on. `0` on a gate with no loop. | **The set is closed.** A rule naming a fifth name does not quietly evaluate to false - it does not compile, and a rule that does not compile **fails the step**, in the run you are looking at. That is deliberate. A `waitForEvent` predicate with a typo silently never matches and you find out by waiting out a timeout; a gate is not something to learn about that way. `iteration` is bound on every path, including the ones with no loop, where it is `0`. So `iteration > 3` outside an agent is false rather than an error - and since an erroring rule [raises the gate](#a-rule-that-cannot-be-evaluated), an unbound name would have gated every call. **Every JSON number reaches CEL as a double**, so `args.amount > 1000` is a double comparison. The payload is parsed without a target type, so an expression that depends on integer semantics does not behave as written. The same trap applies to a trigger `if`, for the same reason. CEL raises an error on a missing key rather than answering false, so guard a field the model may not have produced. An unguarded `args.amount` on a call that sent none errors, and an erroring rule gates: safe, but it wakes a reviewer on every such call. ```ts theme={null} // Errors, and therefore gates, on any call whose args carry no amount. const strict = { tool: "issue-refund", args, if: "args.amount > 1000.0" }; // Answers false on that call, which is what you meant. const guarded = { tool: "issue-refund", args, if: "has(args.amount) && args.amount > 1000.0" }; ``` ### When a rule declines the gate No approval is created, nothing lands in anyone's inbox, and the tool runs. The step is still written, and it completes with the same decision shape a real approval resolves to: | Property | Value | | ----------- | ---------------------------- | | `status` | `"approved"` | | `decision` | `"approve"` | | `args` | the proposed args, unchanged | | `decidedBy` | `"system:rule"` | So the run's own record shows the gate was evaluated and not raised, and `system:rule` reads beside [`system:timeout`](#timeouts-and-escalation): an auditor asking who let a tool run finds a named system actor rather than the blank that would read as an unattributed human. **A declined gate still counts as a durable step.** The rule evaluation is a durable step like any other, so it is billed the same way regardless of whether it raised the gate. ### A rule that cannot be evaluated **An erroring rule raises the gate.** A missing key, a type mismatch, a comparison CEL will not make: each of them parks the run on a person rather than letting the call through. This is the opposite of what a [`waitForEvent`](/core/steps) predicate does, and deliberately so. A waiter whose predicate breaks is treated as *no match*: it stays parked and matures at its own timeout, which is the conservative reading there. Here *no match* means run the tool with nobody watching. A rule nobody can evaluate is not evidence that the call is safe. A rule that will not **compile** is a different failure: that is a bad request rather than a bad evaluation, so the step fails outright instead of gating. ### When a rule beats a hand-written `if` You can always write the condition yourself, and for a workflow a person maintains in an editor it is often the clearest thing to write: ```ts theme={null} if (amount > 1000) { const decision = await ctx.step.approval("refund-gate", { tool: "issue-refund", args }); if (decision.status === "denied") return { outcome: "denied" }; } ``` It is also the whole of what it can be. A condition in your own language is a closure: it lives in the runner that holds it, and nothing outside that runner can read it. | | A hand-written `if` | A rule | | ------------------------------------------ | ----------------------------- | -------------------------------- | | Where the condition lives | your handler's source | the request, as data | | Who can author it | someone editing that file | anyone who can fill in a string | | Readable outside the runner | no | yes | | Stored, versioned and diffed as data | no | yes | | Who evaluates it | your runner, in your language | Duraton, in one CEL dialect | | What the run records when it does not gate | whatever your code did next | the gate, evaluated and declined | The last two rows are the ones that decide it. A hand-written `if` that skips the gate leaves nothing behind saying a gate existed at all, while a rule that declines writes its verdict onto the step. And because Duraton evaluates the expression rather than your runner, `args.amount > 1000.0` means one thing rather than one thing per runner. Reach for a rule when the condition is **policy** - something an operator states, a form edits, or an auditor reads. Reach for a hand-written `if` when the condition is ordinary program logic that happens to sit in front of a gate. ### Behaviour hints never gate `destructiveHint` and its siblings on a tool's [`annotations`](/agent-kit/agents-and-tools#behaviour-hints) describe how a tool behaves. They may inform a default an author then owns, and they do nothing else: `requiresApproval` and its rule are the enforcement boundary, and a hint is never a fifth name in the environment above. MCP states the same rule for the same field names - a client is told never to make tool-use decisions on annotations received from a server it does not trust - and the reason bites hardest on an [attached MCP server](/agent-kit/providers-and-mcp#attaching-an-external-mcp-server), where the hint is written by that server's operator rather than by you. `destructiveHint: true` on its own stops nothing. Set a gate on a tool that is genuinely destructive. ## The four decisions A reviewer does one of four things. They are the same four every agent framework converged on, so a tool gated here behaves the way an author coming from elsewhere expects. | Decision | What runs | What the agent gets back | `status` | | --------- | ---------------------------------- | ---------------------------------- | ---------- | | `approve` | the tool, with the proposed args | the tool's real output | `approved` | | `edit` | the tool, with the reviewer's args | the tool's real output | `approved` | | `reject` | nothing | a denial carrying `reason` | `denied` | | `respond` | nothing | `response`, **as the tool result** | `denied` | `reject` and `respond` are not interchangeable. `reject` says *do not do this, here is why*; `respond` says *do not do this, here is the answer instead* - the human did the tool's job, so their answer is what the tool call returns. Give a reason when you reject. Without one the agent's only honest next move is to try the same call again; with one it can pick a different action, ask a clarifying question, or stop. An [`onTimeout: "reject"`](#timeouts-and-escalation) writes its own reason, so a refusal the clock made is never a blank one. ## The result The step resolves to the decision once it is made: | Property | Type | Description | | ----------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `decision` | `HitlDecision` | What the decider did: `approve`, `edit`, `reject` or `respond`. | | `status` | `"approved"` \| `"denied"` | Where that left the approval. A denial is not an error - the workflow branches on it. | | `args` | `A` | The **effective** arguments: the decider's edits when they changed them, otherwise the proposed `args`. | | `decidedBy` | `string` | Who decided, as recorded by the engine from the authenticated caller - not settable by the request. A [timeout resolution](#timeouts-and-escalation) records `system:timeout`. | | `reason` | `string` | Why the call was refused, on `reject`. | | `response` | `unknown` | The decider's answer standing in for the tool's output, on `respond`. | ## Deciding An open approval shows up in the **Approvals** inbox in the console: the proposed tool call, its risk, the run it belongs to, and an editable view of the arguments. Each of the four decisions resumes the parked run, and each is recorded in the control-action audit log. The console offers only the decisions the request allows. **Approve** becomes **Approve with edits** once you change the arguments, so the verb follows what you actually did rather than needing a second button. **Reject** and **Respond** each ask you to write the refusal first - a rejection with nothing in it leaves the agent to retry the identical call - and a response is required, since it stands in for the tool's output. With `allow: ["approve", "reject"]` the arguments stay locked and no respond affordance appears at all. A decided approval records **how** it was decided as well as by whom - `decidedVia` is `console`, `mcp`, `api`, `timeout`, or `unrecorded`. It is always written by the engine and never settable by the caller: from how the request authenticated, or from the clock when there was no request. The console shows it on the decision ("approved by [alice@example.com](mailto:alice@example.com) via console"), because a person clearing a gate from a signed-in session and an agent clearing it with a write tool are not the same evidence. `timeout` is the case with no caller at all: the approval's own [`onTimeout`](#timeouts-and-escalation) resolved it, which reads as "approved by system:timeout via timeout" and is deliberately not mistakable for a person. See the [approval object reference](/reference/api/approvals#deciding). Every approvals action in the console is also an MCP tool, so an AI agent can work the same inbox - a triage agent that clears routine requests and escalates the rest is a supported use, not a workaround: | Action | REST | MCP tool | | --------------------------------- | ------------------------------- | ------------------ | | List open approvals | `GET /approvals?status=pending` | `list_approvals` | | Inspect one | `GET /approvals/:id` | `get_approval` | | Approve (optionally editing args) | `POST /approvals/:id/decision` | `approve_approval` | | Reject, with a reason | `POST /approvals/:id/decision` | `deny_approval` | The request and decision payloads are in the [approvals API reference](/reference/api/approvals); the tool list is in the [MCP reference](/integrations/mcp-server). ### Who may decide what A gate exists to put a named someone between a proposed action and its execution. You can require that above a chosen risk level, that someone is a **person**: an approval at or above the floor is refused for any caller authenticating as a credential rather than as a signed-in human, and answers `403`. The floor is a platform setting, one of `low`, `medium` or `high`. With a floor of `high`: | Risk | An agent or an API key | A signed-in person | | -------- | ---------------------- | ------------------ | | `low` | decides | decides | | `medium` | decides | decides | | `high` | **refused** | decides | The floor reads the risk **stored on the approval**, and a gate that states no risk is stored at `medium`. A tool gated inside an agent loop states one through [`approval.risk`](/agent-kit/approvals#tools-a-human-has-to-approve); one that does not can never be at or above a floor of `high`, however risky the call actually is. **There is no floor unless you set one**, and that default is deliberate. The floor is only useful where a human has a decision surface to use it from: the Duraton console signs decisions as the person who made them, so it sets `high`. A bare engine with no signed decision surface would make a high-risk gate undecidable by anyone. Set one once your operators have a way to sign a decision as themselves. The check runs when the decision is applied, so it holds identically over the API, over MCP, and over any surface added later - narrowing one of them would leave the rest open. Reading is never restricted: an agent can always list and inspect the inbox and tell its operator what is waiting. The floor bounds the clock as well as callers: a gate at or above it cannot set [`onTimeout: "approve"`](#what-a-deadline-may-not-decide), so a deadline never clears what an API key would have been refused. Set `risk` on the gate to place it (see [The request](#the-request) above). An approval with no risk is never above the floor - classify a call before relying on a gate to hold it. ## Timeouts and escalation `timeout` sets the deadline; `onTimeout` says what reaching it does. **There is no default timeout**: an approval that sets none waits indefinitely, and resumes on a real decision however late it arrives. | `onTimeout` | At the deadline | The approval ends at | The run | | ------------------------ | ---------------------------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------- | | `escalate` **(default)** | flips `pending` to `escalated` and notifies `escalatesTo` | stays open | stays suspended, still waiting for a decision | | `approve` | resolves the gate as an approval of the proposed args | `approved` | resumes with `decision: "approve"`, so the tool call goes ahead | | `reject` | refuses the call, with `no decision before ` as the `reason` | `denied` | resumes; the agent reads the refusal as the tool's result, exactly like a human rejection | | `fail` | fails the parked step | `cancelled` - terminal, with no decision on record | fails with the step | Omitting `onTimeout` is `escalate`, which is what a deadline has always meant here: overdue is not decided. Opt in to the other three per gate, where "nobody looked" has a right answer. ```ts theme={null} // A digest that has to go out on time: if nobody looked, send what was drafted. await ctx.step.approval("digest-gate", { tool: "send-digest", args: { audience: "subscribers" }, risk: "low", timeout: "2h", onTimeout: "approve", }); // A payout the business must never make unattended: no decision is a failure. const payout = await ctx.step.approval("payout-gate", { tool: "release-payout", args: { vendorId, amount }, risk: "high", escalatesTo: "#finance", timeout: "24h", onTimeout: "fail", }); if (payout.status === "denied") return { outcome: "held", reason: payout.reason }; ``` Every resolution the clock made is recorded as `decidedBy: "system:timeout"` and `decidedVia: "timeout"` - see [Deciding](#deciding). Nobody decided, and the record says so rather than leaving an unattributed decision behind. ### What a deadline may not decide Three configurations are refused, all of them cases where the clock would do something no reviewer was offered: | Configuration | Why | | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `onTimeout: "approve"` with `risk: "high"` | An unattended auto-approval is the exact failure a high-risk gate exists to prevent. | | `onTimeout: "approve"` at or above the human-decision floor | A deadline is not a human either, so it must not clear what [an API key would be refused](#who-may-decide-what). | | `onTimeout` naming a verb `allow` leaves out, e.g. `allow: ["reject"]` with `onTimeout: "approve"` | The deadline may only do what the request offered a reviewer. | The engine checks all three **when the approval is created**, not when the deadline arrives: a refused request fails the step immediately, so the author sees it in the run they are looking at rather than hours later in a run nobody is watching. `escalate` and `fail` decide nothing, so `allow` does not constrain them. ## Driving decisions from code The same endpoints back the client, so a test - or a bot that auto-approves low-risk calls - can drive an approval end to end: ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); const [open] = await duraton.approvals.list({ status: "pending" }); if (open.risk === "high") { await duraton.approvals.decide(open.id, { decision: "reject", reason: "over the auto-approval limit - a person has to look at this one", }); } else { // Edit: halve the refund, and the parked run resumes with the new args. await duraton.approvals.decide(open.id, { decision: "edit", args: { orderId: "A1", amount: 2100, currency: "usd", reason: "billing_error" }, }); } ``` The edited run resumes at `refund-gate` and receives the new arguments as `decision.args`; the rejected run resumes with the reason as its tool result, so the agent can act on it. Both decisions stay listable afterwards (`duraton.approvals.list({ runId })`) as the audit trail. `{ status: "approved" }` and `{ status: "denied" }` still decide an approval, and resolve to the same verb the engine would derive - `approved` with edited args is an `edit`, without them an `approve`, and `denied` is a `reject`. Naming the verb is clearer, and it is the only way to `respond`. # Context management Source: https://docs.duraton.ai/ai/context-management Bound what reaches the model each turn. A summary is a durable step, so a replay reads what was written instead of writing it again. `step.ai.loop`'s history array grows by one entry every turn and nothing trims it. An agent that runs long enough eventually hits the model's context window and fails - the only failure mode of its kind, since every other bound the loop enforces (`maxIterations`, `maxApprovals`) is a ceiling you set on purpose. A **context trimmer** is that bound, as a port. Duraton ships three adapters - `count`, `token-budget` and `summarize` - and the port is what lets a later one drop in without touching the loop or the adapters already there. ## Turning it on ```ts theme={null} import { getContextTrimmer } from "@duraton/sdk"; const result = await ctx.step.ai.loop("agent", { prompt: `Resolve this ticket: ${ticket}`, maxIterations: 20, context: getContextTrimmer({ strategy: "count", keepLast: 10 }), tools: { "search-policy": searchPolicy, "issue-refund": issueRefund }, turn, }); ``` With `@duraton/agent-kit` it is a compact descriptor on `agent()` instead - see [Bounding context](/agent-kit/agents-and-tools#bounding-context) for that surface. Omitted, an agent behaves exactly as it did before this option existed: the whole history reaches every turn. ## The three strategies | Strategy | What it does | Calls a model | | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------- | | `count` | Drops the oldest iterations beyond `keepLast` | No | | `token-budget` | Same drop, sized by an estimated token count (`maxTokens`) instead of a count | No | | `summarize` | Overwrites the earliest surviving iteration's own result with a summary of everything older than `keepLast`, and drops the rest | Yes | `count` and `token-budget` never remove anything the run itself remembers - each is a fresh view computed before every turn, so the loop's own history (what a replay reads, what a later `stop` predicate sees) still has every iteration. Recomputing costs nothing, so there is nothing to gain by remembering the last result. `summarize` is different: it pays for its own output; the loop's own history is replaced by the result, so a later turn starts from the already-reduced baseline instead of paying to re-summarize the same growing prefix on every turn. ```ts theme={null} context: getContextTrimmer({ strategy: "summarize", maxTokens: 8000, keepLast: 4, model: "claude-haiku-4-5", // optional - a cheaper model than the agent's own is a common choice generate: async (prompt) => (await provider.generate({ model, prompt })).text, }), ``` `@duraton/agent-kit`'s `agent()` builds `generate` for you from the same provider/model/apiKey every turn already uses - the raw SDK option above takes it directly because `step.ai.loop` has no provider of its own to resolve one from. ## How a summary is represented A summary does not add a new kind of history entry - `LoopIteration` stays exactly `{ toolCalls, toolResults }`, the same shape it has always been. Instead, the earliest surviving iteration's own `toolResults[].output` is overwritten with the summary text, reusing that entry's real `id` and `name` rather than fabricating one: ```json theme={null} // Before: three real tool calls [ { "toolCalls": [{ "id": "c1", "name": "search-policy", "input": { "q": "refund window" } }], "toolResults": [{ "id": "c1", "output": { "days": 30 } }] }, { "toolCalls": [{ "id": "c2", "name": "search-policy", "input": { "q": "exceptions" } }], "toolResults": [{ "id": "c2", "output": { "none": true } }] }, { "toolCalls": [{ "id": "c3", "name": "issue-refund", "input": { "amount": 40 } }], "toolResults": [{ "id": "c3", "output": { "refunded": true } }] } ] // After summarizing with keepLast: 1 - "c1" is reused, "c2" is gone, "c3" (the kept tail) is untouched [ { "toolCalls": [{ "id": "c1", "name": "search-policy", "input": { "q": "refund window" } }], "toolResults": [{ "id": "c1", "output": "Checked refund policy (30-day window, no exceptions found)." }] }, { "toolCalls": [{ "id": "c3", "name": "issue-refund", "input": { "amount": 40 } }], "toolResults": [{ "id": "c3", "output": { "refunded": true } }] } ] ``` This mirrors Anthropic's own [`clear_tool_uses`](https://docs.claude.com/en/docs/build-with-claude/context-editing) context-editing behaviour: an existing entry's content is cleared or replaced in place, never a new one invented. A second summarization folds the same way - the reused entry's current content (raw or already a summary) feeds the next summarization call, so history never carries more than one summary-bearing entry at once. ## The summary is a durable step Every context-trim pass - `count` and `token-budget` included, not only `summarize` - writes its own step, a sibling of the turn step and the guardrail step, never a suffix of either. Two consequences follow from that and from nothing else: * **A replay reads the recorded result.** `summarize`'s model call does not run again, so a re-run of the same run cannot disagree with the original, and the summary is an auditable fact rather than something re-derived on every pass. * **Every adapter gets the same guarantee**, whether or not it happens to call a model. `count` and `token-budget` are pure functions that would be replay-safe either way; wrapping them identically means adding a future model-calling adapter never needs new plumbing. A loop with no `context` option writes no context-trim steps, so turning this on costs nothing until you do. ## Writing a trimmer A trimmer takes the accumulated history and the prompt, and returns a (possibly unchanged) result. It never mutates the caller's history array - it says what the new view should be and the loop applies it. ```ts theme={null} import type { ContextTrimmer } from "@duraton/sdk"; const keepRecentSearches: ContextTrimmer = { name: "count", trim: ({ history }) => { const keepLast = 5; if (history.length <= keepLast) return { history: [...history], trimmed: false, persist: false }; return { history: history.slice(-keepLast), trimmed: true, persist: false }; }, }; ``` `persist` is what separates a cheap per-turn view (`false` - recomputed every time, the loop's own record is untouched) from a result that should become the new baseline (`true` - the loop replaces its own accumulator, so a later turn's trim pass sees the reduced history rather than the original). Only set it `true` when the work being saved by not recomputing is worth the loop's history no longer holding what was replaced. # Cost controls Source: https://docs.duraton.ai/ai/cost-controls Stop an agent before it overspends: cap halts before the call, tokenThrottle spaces out runs, the cache replays identical calls free, fallback survives an outage. AI spend has two shapes of failure: one run runs away, or a burst saturates your provider quota. Duraton gives each its own control, declared on [`workflow`](/reference/sdk/defining-workflows) next to `retry`, plus three per-call options that cut spend and absorb provider failures. ```ts theme={null} workflow({ name: "summarize", cap: { maxCost: 0.25 }, // ceiling on ONE run's spend -> fail tokenThrottle: { tokens: 100_000, perMs: 60_000 }, // token rate -> delay the start handler, }); ``` | Control | Scope | Acts | When it fires | | --------------- | ------------------------------------------------ | ---------------------------------------- | -------------------------------------------------------------- | | `cap` | one run | before a `step.ai` call | The run **fails** with a `BudgetError`. | | `tokenThrottle` | runs sharing a key | at run start, debited after each AI step | The next runs **start later**. | | `cache` | one `generate` call | before the provider call | An identical prior call is served with **zero spend**. | | `promptCache` | one `generate` call, or every turn of an `agent` | on the provider's side | A repeated prefix is billed at the provider's **cached rate**. | | `fallback` | one `generate` call | after a retryable failure | The call **advances** to the next model. | The reference tables for the two workflow fields live in [flow control](/ai/cost-controls); the per-call options are in [`step.ai.generate`](/reference/sdk/ai-steps#step-ai-generate). This page is how to choose between them. ## cap - bound one run `cap` is a hard ceiling on a **single run's** AI spend. The run halts **before** the `step.ai` call that would cross a set axis - that call never runs - and fails with a `BudgetError`. Everything committed before the halt stays committed. ```ts theme={null} workflow({ name: "research.agent", cap: { maxCost: 0.25, maxTokens: 40_000 }, handler: async (ctx) => { await ctx.step.ai.loop("agent", { prompt: ctx.event.data.brief, maxIterations: 20, tools: { search: { handler: (q) => search(q) } }, turn: (c, i) => callModel(c.prompt, c.history, i), }); }, }); ``` | Property | Type | Default | Description | | ----------- | -------------- | ------- | -------------------------------------------------------------------------------- | | `maxCost` | `number` (USD) | off | Halt before a `step.ai` call once the run's summed cost reaches this. | | `maxTokens` | `number` | off | Halt before a `step.ai` call once the run's summed tokens (in + out) reach this. | At least one axis is set. The ceiling is crossed by at most the one call that reaches it: a call's cost is unknown until it returns, so the call that pushes spend to the limit completes and the **next** one halts. **What you see.** A capped run is an ordinary `failed` run in the console, carrying `BudgetError` as its terminal error - there is no separate "capped" state. In an agent loop, the halted turn is the loop's last (failed) iteration. Raise the cap and [replay](/reference/api/runs) the run and it starts fresh with spend back at zero. `maxTokens` always bites - tokens are metered from every model call. `maxCost` bites only when your runner prices its calls through a `resolveCost` map; Duraton holds no price list, so without one the cost axis stays inert. ## tokenThrottle - bound the rate A `tokenThrottle` protects **your own provider quota**: at most `tokens` spent per `perMs` across the runs sharing a key. Duraton debits each AI step's **actual** token usage after the step commits, and when the bucket is drained it delays new run **starts** for that key - the run waits in the queue holding no runner, then runs normally. ```ts theme={null} workflow({ name: "enrich.contact", tokenThrottle: { tokens: 100_000, perMs: 60_000, key: "customerId" }, // 100k tokens/min per customer handler, }); ``` | Property | Type | Default | Description | | -------- | ------------- | -------------- | ------------------------------------------------------------------------------------------------ | | `tokens` | `number` | required | The token budget per window (in + out) across the runs sharing the key. Positive. | | `perMs` | `number` (ms) | required | The window the token budget refills over. Positive. | | `key` | `string` | whole workflow | An event-data path (e.g. `"customerId"`, `"user.id"`); each value gets its own independent rate. | Because a step's tokens are known only after it runs, the throttle gates a run's start on the key's **recent** usage: a key that has recently spent heavily has its next runs spread out, a fresh key starts immediately. It shapes the rate of starts, not any single run - pair it with a `cap` to also bound one run. **What you see.** A throttled run sits in `queued` with a future start time, then runs normally. There is no new state to handle. ## cache - don't pay twice for the same call The [inference cache](/reference/sdk/ai-steps#inference-cache) is the one control that *reduces* spend rather than bounding it. On a hit the provider is never called, so the step commits with **zero spend** and counts nothing against `cap` or `tokenThrottle`. Where step memoization makes a **replay** free, the cache makes an identical call in a **different run** free too. ```ts theme={null} const answer = await ctx.step.ai.generate("answer", { model: "claude-opus-4-8", prompt: `Answer from this policy doc:\n${doc}\n\nQ: ${question}`, temperature: 0, // required - caching engages only for a deterministic call cache: { ttlMs: 3_600_000 }, }); ``` | Property | Type | Default | Description | | ------------- | --------------------------- | ------------------ | -------------------------------------------------------------------- | | `cache` | `boolean` \| `CacheOptions` | off | `true` opts the call in with the defaults; an object overrides them. | | `cache.ttlMs` | `number` (ms) | `86_400_000` (24h) | How long an entry stays servable. | | `cache.seed` | `string` | your app name | Scopes entries further; entries never cross a project boundary. | The key is an exact match over the seed, the provider, the model, the prompt, and every output-affecting parameter (`system`, `temperature`, `maxTokens`, `output`), so a changed prompt or model never returns a stale answer. `apiKey` is never part of the key. Caching engages only when `temperature` is **explicitly** set to `0.2` or lower. An unset `temperature` is treated as non-deterministic (a provider default is often 1.0), so `cache: true` with no `temperature` is a documented no-op - the call runs and is charged. **What you see.** A cache-served step shows a cache pill in the run inspector carrying `hit`, `key`, and `ageMs`, with zero tokens recorded. The console's **AI** view has a **cache hit** rate for the window. ## promptCache - pay the cached rate for a repeated prefix `promptCache` opts a call into **the provider's own prompt cache**: the provider still runs the call, and the only thing that changes is what it charges for the part of the request it has already seen. That is the opposite trade to `cache` above - the [inference cache](/reference/sdk/ai-steps#inference-cache) skips the provider call entirely on an exact repeat, while this one keeps calling and re-prices the repeated prefix. The two are independent and can be set together. ```ts theme={null} const answer = await ctx.step.ai.generate("answer", { model: "claude-opus-4-8", system: policyDoc, // the stable head every question re-sends prompt: question, promptCache: "prefix", }); ``` ```ts theme={null} const result = await agent(ctx, "triage", { model: "claude-opus-4-8", prompt: ticket.body, tools: [searchKb], maxIterations: 6, promptCache: "conversation", }); ``` | Property | Type | Default | Description | | ------------- | ------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `promptCache` | `"prefix"` \| `"conversation"` | off | Which part of the request the provider is asked to hold. Unset, the request is byte-identical to one made before the option existed. | `"prefix"` caches the **static head** - the tool declarations and the system prompt - which is what many calls sharing one long instruction block but ending differently need. `"conversation"` caches that head *and* follows the transcript as it grows, so turn N+1 reads turn N's exchanges back instead of paying to process them again; that is the scope an agent wants. Duraton sends no TTL of its own, so an entry lives for whatever the provider defaults to (five minutes, on Anthropic). **It refuses rather than quietly ignoring you.** The option only reaches a provider that declares the `prompt-cache` capability; asking any other adapter fails the call with an error saying the provider `cannot place a cache breakpoint`. Of the two built-in adapters only `anthropic` declares it - see [Prompt caching](/reference/sdk/ai-steps#prompt-caching). Silence would be worse than a failure here: an adapter that dropped the field would still answer correctly, just at the full input price forever, and both cache axes would read zero - indistinguishable from a cache that was asked for and missed. **What you see.** The step's AI journal gains `cacheReadTokens` and `cacheCreationTokens` once the provider reports them, and the run inspector's AI pane shows them as **cache read** and **cache write**. They are separate axes from `tokensIn`, not a slice of it - see [token and cost spend](/ai/observability#token-and-cost-spend). The console's **cache hit** rate is the inference cache's: a call that used only `promptCache` never enters that denominator. A cache write costs more than a plain call and a read costs a fraction of one, so a prefix cached and never read back is a loss. It pays from the second call on - a long system prompt many calls share, or an agent transcript re-sent every turn. ## fallback - survive a rate-limited model A [fallback chain](/reference/sdk/ai-steps#fallback-chains) keeps one call alive when a model is rate-limited or down. The primary `model` is tried first; a **retryable** failure (429, a 5xx, or a timeout) advances to the next candidate, and the first to return wins. Its result is the step's durable output, so the caller never sees the failover. ```ts theme={null} const answer = await ctx.step.ai.generate("answer", { model: "claude-opus-4-8", prompt: question, fallback: [{ model: "claude-sonnet-4-6" }, { model: "claude-haiku-4-5" }], }); ``` | Property | Type | Default | Description | | --------------------- | --------------------- | ------------------- | -------------------------------------------------------- | | `fallback` | `FallbackCandidate[]` | off | Backup models tried in order after `model`. | | `fallback[].model` | `string` | required | The candidate model id. | | `fallback[].provider` | `ProviderName` | the call's provider | The candidate's provider, so a chain can span providers. | Only 429, 5xx, and timeout advance the chain. A terminal 4xx (a malformed request, an auth failure) fails the step immediately - another model will not fix a bad request - and an exhausted chain fails the step too, re-throwing the last error so the workflow's own [retry policy](/core/retries) still applies. Falling back to a cheaper model changes what the call costs, so a chain interacts with `cap` through whichever model actually served. **What you see.** The step shows a chain pill carrying `chain` (the models tried, in order), `used` (the one that served), and `reason` (why the chain advanced, e.g. `"claude-opus-4-8: 429"`). ## Watching spend For totals rather than ceilings, read the window's spend, tokens, average latency, and cache-hit rate, broken down by hour, by model, and by workflow, from [AI observability](/ai/observability). An agent reads the same numbers through the [`ai_spend`](/integrations/mcp-server) MCP tool. # Guardrails Source: https://docs.duraton.ai/ai/guardrails Check a model's tool arguments before the tool runs. The verdict is a durable step, so a replay reads what was decided instead of deciding again. A model authors the arguments its tools are called with. Left unchecked, those arguments reach your handler - and whatever it talks to - exactly as the model wrote them. MCP puts this on the server side without qualification: *"Servers **MUST**: Validate all tool inputs"* ([Tools, Security Considerations](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)). A **guardrail** is that check, as a port. Duraton ships three adapters and the port is what lets another drop in without touching the loop or the adapters already there. The same port also backs `step.ai.check`, so a policy you write once can guard a tool call, a prompt, a completion, or any call of your own. ## Turning it on ```sh theme={null} npm install @cfworker/json-schema ``` The SDK bundles no JSON Schema engine, so the `schema` adapter loads one at the moment it is first used. `@cfworker/json-schema` is the one to install: it does no code generation, which is what lets it run under a strict CSP and on edge runtimes where `new Function` is unavailable. ```ts theme={null} import { createSchemaGuardrail } from "@duraton/sdk"; const result = await ctx.step.ai.loop("agent", { prompt: `Resolve this ticket: ${ticket}`, maxIterations: 6, guardrails: [createSchemaGuardrail()], tools: { "issue-credit": { inputSchema: { type: "object", properties: { amount: { type: "number" } }, required: ["amount"], additionalProperties: false, }, handler: (input) => credit(input), }, }, turn, }); ``` With `@duraton/agent-kit` it is the same option on `agent()`: ```ts theme={null} const result = await agent(ctx, "agent", { model: "claude-opus-4-8", prompt: `Resolve this ticket: ${ticket}`, tools: [issueCredit], maxIterations: 6, guardrails: [createSchemaGuardrail()], }); ``` A tool that declares no `inputSchema` is allowed through - there is nothing to check it against. ## The adapters that ship | Adapter | What it checks | Default action | Needs | | ------------ | ----------------------------------------------------------------------------------------- | -------------- | ----------------------- | | `schema` | A tool call against that tool's own `inputSchema` | `deny` | `@cfworker/json-schema` | | `pii` | Text for email addresses, E.164 phone numbers, Luhn-valid card numbers and IPv4 addresses | `mask` | nothing | | `moderation` | Text, through a classifier you supply | `halt` | your classifier | `schema` and `pii` are deterministic and call nothing, so they are safe to leave on. `moderation` calls out, which is why the classifier is yours: ```ts theme={null} import { createModerationGuardrail, createPiiGuardrail } from "@duraton/sdk"; const policies = [ createPiiGuardrail({ rules: ["credit-card", "email"] }), createModerationGuardrail({ classify: async (text) => { const verdict = await myClassifier(text); return { flagged: verdict.blocked, categories: verdict.categories }; }, threshold: 0.8, }), ]; ``` Duraton holds no moderation model and ships no default one: what counts as acceptable belongs to the people running the agent, not to the runtime. `getGuardrail("moderation")` therefore returns an adapter that **faults** rather than allowing - a name lookup that could not find a classifier must never read as a clean bill of health. `pii` masks by default rather than refusing, because the request minus the identifier is usually still the request. Set `action` to `deny` or `halt` for data that must not travel at all, and `observe` to measure a new rule against real traffic before it refuses anything. ## What a block looks like to the model A refused call does **not** throw. It comes back as that tool's result, so the model reads why it was refused and can correct itself on the next turn: ```json theme={null} { "valid": false, "tool": "issue-credit", "error": "arguments for tool \"issue-credit\" do not match its inputSchema - #: Instance does not have required property \"amount\".; #: Property \"reason\" does not match additional properties schema.; #/reason: False boolean schema." } ``` That is the model calling `issue-credit` with `{ "reason": "duplicate" }` against the schema above. Every failing keyword is listed, located by JSON Pointer, so the model can fix them all in one turn rather than one per turn - and each extra turn would be another model call and another durable step. This is the same shape a human denial produces from an approval gate, and for the same reason: MCP classes invalid input data as a *tool execution error reported in the result*, not a protocol error. A thrown error would end the run and teach the model nothing. The refusal carries no decider. Only a person's decision on an approval names a person; a policy refusing a call is not a person saying no, and the two never share a shape. ## When a tripwire halts the run `halt` is the strongest verdict, and it is **not** an error. Inside `step.ai.loop` the loop ends with its own stop reason and names the policy that decided: ```ts theme={null} const result = await ctx.step.ai.loop("agent", { guardrails: policies, /* ... */ }); if (result.stopReason === "guardrail") { await notifyCompliance({ run: ctx.runId, policy: result.haltedBy }); } ``` `stopReason: "guardrail"` sits alongside `"max-iterations"` and `"approval-budget"` in the same closed set, so a halted agent reads as a ceiling that fired rather than as a run that broke, and `result.haltedBy` is the guardrail's name. That distinction is load-bearing rather than cosmetic. A tool handler runs inside a durable step, so a thrown error commits as that **step's failure** - a control that worked would be recorded as an agent that broke, and every replay of that run would reproduce the failure forever. The halt travels out of the turn as a value instead, for the same reason [`bail()`](/agent-kit/agents-and-tools) does. Two consequences follow: * **Every sibling tool call in the halting turn still completes and still lands on the record.** A tool whose side effect already happened is never dropped just because another call was refused. * **A halt outranks a `bail()` in the same turn.** `bail` is the agent deciding it is done, and a policy that refused the turn is not something the agent gets to overrule. Outside a loop, `step.ai.check` has no loop to end, so a `halt` there fails the run **non-retriably** instead: the check's own step commits the failure, so the run stops at its last checkpoint rather than re-taking a decision that is already committed until its attempt budget runs out. ## Checking a value on its own `step.ai.check` runs the same policies over anything - a prompt before it reaches a provider, a completion before you use it, a payload before it leaves the runner - and, optionally, guards one call with them. ```ts theme={null} import { createModerationGuardrail } from "@duraton/sdk"; const gate = await ctx.step.ai.check("moderate", { guardrails: [createModerationGuardrail({ classify })], input: { placement: "pre-prompt", value: question }, output: (answer) => ({ placement: "post-model", value: answer }), call: () => askTheModel(question), }); if (gate.tripped) return { refused: gate.verdict.by }; return { answer: gate.result }; ``` The policies to ask, in order; the first verdict that is not allow wins. A guardrail that does not declare the subject's placement is skipped. What to check before the guarded call - the placement, the value, and optionally a schema and a tool name. Derives what to check from the call's result. Omitted, nothing is checked afterwards. The call the checks guard. Omitted, check is a plain policy evaluation over a value you already have. Race the input check against the call instead of gating the call on it. It returns: The verdict that decided: the output check's when one ran and the input check cleared, else the input check's. Whether the check refused to clear what it was given. deny and approve trip; mask and rewrite do not, because they hand back a replacement. What to use in place of the value you offered - the replacement under mask or rewrite, else the value as given. Absent when the check tripped. The guarded call's own return. Absent when no call was given, when a check tripped before or during it, and when the output check refused it. ### Running the check beside the call With `parallel: true` the check and the call start together, so you pay the check's latency concurrently instead of in front of the call. The moment the policy trips, the call's `AbortSignal` is aborted and its result never reaches you: ```ts theme={null} const gate = await ctx.step.ai.check("moderate", { guardrails: [createModerationGuardrail({ classify })], input: { placement: "pre-prompt", value: question }, parallel: true, call: (signal) => askTheModel(question, { signal }), }); ``` The trade is a call you may throw away, which is the right trade when the classifier is fast and the model call is the slow part. A cancelled call still commits its step, recorded as cancelled - the run says a policy stopped it rather than leaving a hole where a call should be. A `deny` from `step.ai.check` does not throw. There is no model to hand a refusal back to, so the trip comes back as `tripped` for your own code to act on. Only `halt` ends the run. ## The verdict is a durable step Each guardrail pass in a loop writes its own step - a sibling of the tool step and the approval step, never a suffix of either. A `step.ai.check` writes three: its input check, the call it guards, and its output check. Two consequences follow from that and from nothing else: * **A replay reads the recorded verdict.** The detector does not run again, so a re-run of the same run cannot disagree with the original, and a verdict is an auditable fact rather than something re-derived on every pass. * **A refused call writes no tool step at all.** The verdict step exists, the tool step does not, which is what makes "the handler never ran" checkable from the run record. A loop with no guardrails writes no guardrail steps, so turning this on costs nothing until you do. In the console, a turn that ran guardrails says how many it checked, and any verdict that did not allow the call gets its own row naming the tool, what happened to it, and which guardrail decided - `issue-credit denied by schema`. The row is drawn from the run's event stream, which carries the action and the guardrail but never the verdict's `reason`, so it is safe to read for someone who should not see the value that was refused. ## When a detector is down A guardrail that throws does not read as "clean". It produces a verdict whose `action` is `deny` and whose `outcome` is `partial`, meaning the check could not complete. Nothing in the loop treats `partial` as an allow. | `outcome` | Meaning | | ---------- | ------------------------------------------------------------------ | | `complete` | Every guardrail ran | | `partial` | At least one did not run; the result is not a clean bill of health | | `failed` | The check itself failed outright | ## Writing a guardrail A guardrail declares which placements it understands and returns a verdict. It never mutates the caller's state - it says what should happen and the loop applies it. ```ts theme={null} import type { Guardrail } from "@duraton/sdk"; const noExternalRecipients: Guardrail = { name: "recipient-policy", placements: ["tool-args"], check: async ({ value, tool }) => { const to = (value as { to?: string }).to ?? ""; if (to.endsWith("@example.com")) { return { action: "allow", outcome: "complete", detections: [] }; } return { action: "deny", outcome: "complete", detections: [{ rule: "recipient.external", detected: true }], reason: `${tool} may only send to internal recipients`, }; }, }; ``` Pass it alongside the others. They run in the order you list them and the first verdict that is not `allow` wins, so a later guardrail can never overturn an earlier refusal. ### Placements Where a guardrail runs. `tool-args` is the placement the loop evaluates today; the rest are part of the contract so an adapter written now stays valid as they are wired. | Placement | What it inspects | | ------------- | ----------------------------------------------------------- | | `pre-prompt` | The prompt and system text about to reach a provider | | `post-model` | A completed model response, before anything is done with it | | `tool-args` | The arguments a model proposed, before the tool step runs | | `tool-result` | A tool's output, before it re-enters the transcript | | `egress` | A URL, host or payload about to leave the runner | ### Actions What a verdict asks for. | Action | Effect at `tool-args` | | --------- | ----------------------------------------------------------------------------------------------- | | `allow` | The tool runs with the model's own arguments | | `observe` | Detected but deliberately not enforced - the run is unchanged, the detection is recorded | | `mask` | The tool runs with `payload` instead; the original never reaches the handler or the step record | | `rewrite` | As `mask`, for a repaired rather than a redacted value | | `deny` | The tool does not run; the reason goes back to the model as that tool's result | | `approve` | The call is parked on a human, even if the tool itself was not marked as needing approval | | `halt` | The loop ends with `stopReason: "guardrail"`, naming the rule; a `step.ai.check` fails the run | `observe` is how a new rule earns its place: turn it on, let it record what it *would* have refused, and only then promote it to `deny`. ## Which dialect a schema is read in JSON Schema leaves the dialect of a schema with no `$schema` up to the implementation ([2020-12 core §8.1.1](https://json-schema.org/draft/2020-12/json-schema-core#name-the-schema-keyword)). Duraton reads the dialect the schema declares when it declares one, and otherwise uses **2020-12**. Override the fallback if your tool schemas are written against an older draft: ```ts theme={null} createSchemaGuardrail({ draft: "7" }); ``` Supported drafts: `4`, `7`, `2019-09`, `2020-12`. ## Edits a person makes are checked too When a tool is approval-gated and the reviewer edits the arguments before approving, those edits are hand-typed JSON that the model never proposed - so they are re-checked, under a durable step of their own. A refusal there fails the run rather than returning a refusal to the model: telling the model its own arguments were wrong would be false when a person is the one who broke them. # AI Source: https://docs.duraton.ai/ai/index Run AI agents that ask a human before the risky move, pick up where they stopped after a crash without paying the model twice, and record what every run cost. This page is for the code path - calling a model as a durable step by hand. To build an agent without code, start with [Build your first agent](/start/first-agent). Duraton is where your AI agents run. They park on a human decision when the next move is risky, and wait as long as it takes. They pick up where they stopped after a crash, a restart, or a deploy - never redoing a model call that already finished. And every run keeps a record of what it did and what it cost. The mechanism is one idea applied to model calls: `ctx.step.ai` makes a call a **durable step**. It runs once, its result is recorded under the step id, and a retry after a crash returns the saved result instead of paying for the model again. Approvals, spend caps, guardrails, and streaming are all built on that same step record. **Keep your framework.** Duraton goes underneath it: your code still decides what the agent does; Duraton makes sure it finishes, stops it before it overspends or acts without a person, and keeps the record. Wrap an existing model call with [`step.ai.wrap`](/reference/sdk/ai-steps#step-ai-wrap) and nothing else changes. ```ts theme={null} const { text } = await ctx.step.ai.generate("classify", { model: "claude-opus-4-8", prompt: `Classify this ticket: ${subject}`, }); ``` ## What each page answers | You want | Read | The mechanism | | ------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | | "It must not act without a human on the risky stuff" | [Approvals](/ai/approvals) | `step.approval` parks the run holding no worker; approve, deny, or approve-with-edits resumes it at the exact checkpoint | | "It must never run a tool call the model got wrong" | [Guardrails](/ai/guardrails) | An automatic check on the arguments the model produced, before the tool runs; the verdict is a durable step | | "My agent spends money and I can't sleep" | [Cost controls](/ai/cost-controls) | `cap` halts **before** the call that would cross the ceiling; `tokenThrottle` spaces out runs that share a key | | "The model is down and my agent just fails" | [Cost controls](/ai/cost-controls) | Fallback chains advance to the next candidate; the inference cache serves an identical deterministic call at zero spend | | "My long agent dies halfway and starts over" | [AI agents](/ai/ai-steps) | One durable step per model turn and per tool call; the loop resumes at the last committed turn | | "The conversation outgrows the context window" | [Context management](/ai/context-management) | A trimming strategy runs as a durable step, so a replay sees the same summary | | "I want to see what it's doing right now" | [Streaming](/ai/streaming) | Tokens stream live from the step and are replayable from token 0 | | "I need to show someone what the agent did, and what it cost" | [AI observability](/ai/observability) | Token and cost rollups, sessions, and traces read from the durable journal | ## Your provider key stays with you The model call happens **in your runner**, with your provider SDK and your key. Duraton records the call's metadata (model, token counts, latency) and never receives your prompt, the response text, or your API key: `apiKey` is passed per call into the provider client and is never persisted by the SDK or sent to Duraton. Omit it and the provider SDK reads its conventional env var (`ANTHROPIC_API_KEY`). This describes running your own code with the SDK. A no-code workflow has no runner of its own, so it resolves a key you add once under [Credentials](/integrations/credentials) - still never persisted anywhere but that one encrypted record, and still resolved fresh for each call rather than held in memory between them. ```sh theme={null} export ANTHROPIC_API_KEY="sk-ant-..." ``` ## Where to start Add your first durable AI step, trigger it, and watch spend land in the console. `agent()` and `tool()`: write an agent whose every turn and tool call is a durable step. Every option and return shape for `generate`, `wrap`, `embed`, and `loop`. Point Claude, Cursor, or any MCP client at these docs and at your project. # AI observability Source: https://docs.duraton.ai/ai/observability Show someone what an agent did and what it cost: token and cost rollups, conversation sessions, run time-series, and GenAI spans read from the durable journal. Every [`step.ai`](/ai/ai-steps) call records a journal block: the model that answered, its token usage, and the call shape. Every surface on this page is a read over that journal. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); const spend = await duraton.ai.spend({ since: "2026-07-01T00:00:00Z" }); console.log(spend.tokens, "tokens across", spend.calls, "calls"); ``` The journal holds metering facts - model name, token counts, an optional supplied cost. Your prompt, the response text, and your provider key stay in your runner: the provider key is passed per call to the provider SDK and is never sent to Duraton. ## Token and cost spend `duraton.ai.spend()` ([`GET /ai/spend`](/reference/api/runs#ai-spend-&-sessions)) rolls the journal up across a project: window totals plus breakdowns by hour, by model, and by workflow. ```ts theme={null} const spend = await duraton.ai.spend({ app: "assistant", since: "2026-07-01T00:00:00Z" }); for (const m of spend.byModel) console.log(m.model, m.tokens, m.cost ?? "(no price)"); ``` Total input + output tokens in the window. Total supplied cost - absent when no call in the window reported one. Journaled model calls (steps that recorded a model). The bucket width used for the hourly series. Spend per time bucket: ts, tokens, cost?, calls. Per-model rollup: model, tokens, cost?, calls, avgLatencyMs? (mean latency for that model, absent when none reported one). Per-workflow rollup: workflow, app, tokens, cost?, runs. Mean call latency over calls that reported one - absent when none did, never 0-filled. Calls in the window served from the inference cache. Calls that used the cache at all; cacheHits / cacheEligible is the hit rate. `spend({ app, workflow, since, bucket })` scopes the rollup: `app` and `workflow` filter it, `since` sets the window (an RFC3339 string or a `Date`), and `bucket` sets the hourly bucket width in seconds (default `3600`). ### Tokens always, cost only when supplied Duraton meters tokens and holds no price list, so `tokens` is always present while `cost` appears only where a call supplied one. The distinction between "no cost reported" and "zero cost" is preserved: every `cost` field stays absent rather than defaulting to `0`. Supply prices with the `resolveCost` option on `connect()` and every rollup above carries cost too. Metering is read-only. To *enforce* a ceiling, declare a [spend cap](/ai/cost-controls) or a [token throttle](/ai/cost-controls) on the workflow. `maxTokens` always applies; `maxCost` bites only once `resolveCost` supplies a price. ### The four token axes A call meters **four** token counts, and they are disjoint. `tokensIn` is the **uncached** input only, so `tokensIn + cacheReadTokens + cacheCreationTokens` is everything the provider processed, and `tokensOut` is what it wrote back. Nothing is double-counted, so the axes can be summed without knowing which provider answered: an adapter for a vendor that reports an inclusive prompt total subtracts before filling them in. The two cache axes are [the provider's own prompt cache](/ai/cost-controls) - what a call opts into with `promptCache` - and not the inference cache, whose hit rate is `cacheHits` / `cacheEligible` above. They stay absent rather than `0` when the provider reported neither, the same rule `cost` follows. Their names differ by surface. The `usage` your `generate` call returns spells the first two `inputTokens` and `outputTokens`; a step's `ai` journal and the run usage on the wire spell them `tokensIn` and `tokensOut`. The read axis is `cacheReadTokens` on all three. The write axis is the one real rename: `cacheCreationTokens` in the SDK and on the journal, **`cacheWriteTokens`** on the wire - so a run's usage read back from the API names it differently from the result your own call handed you. ## Conversation sessions Runs that belong to the same conversation form a **session**. Set an event's `session` to a stable conversation id and every run it starts joins that session; omit it and each run is a session of one. ```ts theme={null} await duraton.events.send({ name: "chat.message", app: "assistant", session: conversationId, // the OpenTelemetry gen_ai.conversation.id data: { text }, }); for (const s of await duraton.sessions.list({ app: "assistant" })) { console.log(s.session, s.runCount, "runs,", s.aiTokens ?? 0, "tokens"); } ``` The conversation id (or a run's own id when it started none). Runs in the session. Runs per status; only statuses present appear. Total AI tokens across the session - absent when no run made a model call. Total supplied cost - absent when no run in the session reported one. When the earliest run in the session started. When the most recent run started. `list({ app, since, limit })` filters by app, bounds the window with `since`, and caps how many sessions come back (default `100`, max `500`). ## Run counts over time `runs.stats()` returns the current per-status counts plus p50/p95 latency; `runs.timeseries()` returns them bucketed over time, with a latency summary (average, longest, p50, p95) per bucket. These are the two reads behind the console's run charts. ```ts theme={null} const stats = await duraton.runs.stats({ app: "assistant" }); console.log(stats.succeeded, "/", stats.total, "-", stats.successRate); const series = await duraton.runs.timeseries({ since: "2026-07-01T00:00:00Z", bucket: 3600 }); for (const b of series.buckets) console.log(b.ts, b.total, b.avgMs ?? "(no terminal run)"); ``` | Read | Returns | | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /runs/stats` | `total`, `active`, `queued`, `running`, `succeeded`, `failed`, `successRate`, plus `p50Ms` / `p95Ms` over the filter's finished runs (both absent when none have finished). | | `GET /runs/timeseries` | Buckets of `ts`, per-status `counts`, `total`, plus `avgMs` / `maxMs` / `p50Ms` / `p95Ms` over the bucket's terminal runs (all absent when a bucket has none). | Both accept `app`, `workflow`, and `since`; `timeseries` also takes `bucket` (width in seconds, default `3600`). ### Latency percentiles `p50Ms` is the median finished-run duration and `p95Ms` the 95th percentile, both in whole milliseconds and over the same population as `avgMs` / `maxMs` (the scope's, or bucket's, finished runs). They are continuous percentiles with linear interpolation between adjacent durations, so a p95 may fall between two observed values rather than on one - the standard reading of "95% of runs finished at or below this." Percentiles use a single continuous definition (linear interpolation between adjacent durations), so a percentile is never an average relabelled: `p95` is the duration at or below which 95% of the matched runs finished. ## Logs and the run timeline [`ctx.log`](/core/logging) lines and every status transition append to one durable per-run timeline. Read it as history with `runs.logs(id)`, or tail it live with [`runs.watch(id)`](/core/realtime). ```ts theme={null} for await (const frame of duraton.runs.watch(runId)) { if (frame.kind === "log") console.log(frame.level, frame.message); } ``` ## Reading an agent loop as it runs An agent's durable steps are the record of what it did. `agent_event` frames are what make that record readable as turns while the run is still going - they arrive on the same per-run timeline, so one `runs.watch` sees them beside logs and status changes. ```ts theme={null} for await (const frame of duraton.runs.watch(runId)) { if (frame.kind !== "agent_event") continue; if (frame.event === "turn.finished") console.log(frame.iteration, frame.cost); if (frame.event === "agent.finished") console.log(frame.stopReason, frame.stoppedBy); } ``` | Event | Raised when | Carries | | -------------------------------------- | ----------------------------- | ----------------------------------------------------- | | `turn.started` | a turn begins | `agent`, `step`, `iteration` | | `turn.finished` | its model call completes | `model`, `tokensIn`, `tokensOut`, `cost`, `latencyMs` | | `tool.started` / `tool.finished` | a tool call runs | `tool`, `callId` | | `guardrail.verdict` | a policy judges a call | `guardrail`, `action` | | `approval.raised` / `approval.decided` | a gated call reaches a person | `decision`, `decidedBy` | | `agent.finished` | the loop ends | `stopReason`, `stoppedBy`, `guardrail` | `cost` is present only when a cost source priced the call - Duraton holds no price list, so an unpriced turn carries no cost rather than a zero. Likewise a turn served from the inference cache reports a real `tokensIn: 0`, which is a different fact from carrying no token count at all. These frames carry no free text by construction: no prompt, no completion, no tool arguments, no tool results, and no guardrail reason (a reason quotes the value it refused). That is the same invariant the [AI journal](/ai/ai-steps) holds, and it is what makes a trace safe to hand an operator who does not own the agent's code. They stay on the per-run watch and never join the project-wide transition stream, which carries run and step status only. A run makes many passes over the same loop - parking on a human, resuming after a crash - so an event is deduped on the run, attempt, step and event name where it is stored: a re-reported event is a no-op, never a duplicate row. A turn that [streams](/ai/streaming#streaming-agent-turns) records two more facts on its own journal: the `stream` flag and `ttftMs`, that turn's time to first token, so latency is readable per turn rather than per run. Its deltas arrive as `ai_chunk` frames rather than `agent_event` ones, and those do carry the model's completion text - the journal still holds none. ## Traces in your own backend The spans for your **step bodies** are emitted **in your runner process** by the SDK, and exported by whatever OpenTelemetry provider you register there (a `NodeSDK`, for example). Register none and every step span is a no-op. Duraton runs the engine for you, so its internal telemetry is the platform's to operate; what reaches your own backend is what your runner emits. ```ts theme={null} import { NodeSDK } from "@opentelemetry/sdk-node"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; new NodeSDK({ traceExporter: new OTLPTraceExporter({ url: "https://otlp.example.com/v1/traces" }) }).start(); ``` Duraton sends the run's W3C trace context with every invoke, so one run is one trace id: every pass span and every step span of that run - across retries, and across the runners it touches - joins it. A `step.ai` span carries the OpenTelemetry [GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai), so a vendor-neutral backend reads it as a model call with no custom mapping. Only response-side facts the journal holds are emitted; a field it does not hold (temperature, max tokens, the requested model before a fallback) is absent, never guessed. | Attribute | On a `step.ai` span | | ------------------------------------------ | ----------------------------------------------------------------- | | `gen_ai.operation.name` | `chat` for `generate` / `wrap` / `loop`, `embeddings` for `embed` | | `gen_ai.provider.name` | The provider that served the call, when known | | `gen_ai.response.model` | The model that actually answered (post-fallback) | | `gen_ai.usage.input_tokens` | Input token count | | `gen_ai.usage.output_tokens` | Output token count | | `gen_ai.usage.cache_read.input_tokens` | Input tokens served from a provider cache, when reported | | `gen_ai.usage.cache_creation.input_tokens` | Input tokens written to a provider cache, when reported | | `gen_ai.response.finish_reasons` | Why generation stopped, as a one-element array | The span is named `{operation} {model}` (e.g. `chat claude-opus-4-8`), or the bare operation when the model is unknown - the convention's own naming rule. Facts the convention has no attribute for live under a `duraton.*` vendor prefix, so a standard backend ignores them: | Attribute | Meaning | | ------------------------------------------- | --------------------------------------------------------------------------------------------------- | | `duraton.run.id` / `duraton.step.name` | Correlate the span back to its run and step | | `duraton.ai.kind` | The exact call kind (`generate` / `wrap` / `embed` / `loop`) that `gen_ai.operation.name` collapses | | `duraton.ai.wraps` | The client library a `wrap` recognized (e.g. `openai`) | | `duraton.ai.batches` / `duraton.ai.dims` | `embed` batch count and vector dimensions | | `duraton.ai.iteration` / `duraton.ai.tools` | `loop` turn index and the tools available | | `duraton.ai.reask` | The durable re-ask attempt index | ## From an AI assistant The same rollups are [MCP](/integrations/mcp-server) tools: `ai_spend` returns the spend rollup and `list_sessions` returns the conversation list, both scoped to the caller's project. Paired with the read tools for runs and steps, an assistant can answer "which workflow is burning the most tokens" against your live project. ## In the console | View | Shows | Reads | | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | | **AI** | Tiles for spend, tokens, avg latency, and cache-hit rate, then charts by hour, model, and workflow; a **Sessions** table of conversations. | `/ai/spend`, `/sessions` | | **Runs** | Token and cost per run. | `/runs` | # Streaming Source: https://docs.duraton.ai/ai/streaming Show a viewer tokens as the model produces them and still get one durable result - the stream replays from token 0, the memoized value is the full text. A [`step.ai.generate`](/reference/sdk/ai-steps#step-ai-generate) call can **stream** the model's tokens as they arrive: each delta is appended to the run's durable [timeline](/core/realtime) as an `ai_chunk` frame. The step's durable result is still the complete text, reused on replay, so streaming changes what a viewer sees while the step runs, never what the step produces. An agent turn streams the same way - see [Streaming agent turns](#streaming-agent-turns). ## Turning on streaming Pass `stream: true` to `generate`: ```ts theme={null} const result = await ctx.step.ai.generate("summarize-thread", { model: "claude-opus-4-8", prompt: `Summarize this support thread:\n\n${thread}`, stream: true, }); // result.text is the complete summary - identical to a non-streaming call. ``` The return value is unchanged: `result.text` is the full response and the usage counts are the same. `stream: true` only adds the live delta feed alongside the durable result. Live deltas travel over the [connect](/reference/sdk/connect) runner's socket back to Duraton. ## Streaming agent turns An agent's turns stream on the same terms. `stream: true` on [`agent()`](/agent-kit/agents-and-tools) opts every turn in; on [`step.ai.loop`](/reference/sdk/ai-steps#step-ai-loop) it does the same for a loop whose `turn` you write yourself, where `ctx.onDelta` is where that turn sends its deltas: ```ts theme={null} const result = await agent(ctx, "triage", { model: "claude-opus-4-8", prompt: ticket.body, tools: [searchKb], maxIterations: 6, stream: true, }); ``` ```ts theme={null} const result = await ctx.step.ai.loop("triage", { prompt: ticket.body, maxIterations: 6, stream: true, tools: { "search-kb": { handler: searchKb } }, turn: (loop, iteration) => callModel(loop.prompt, loop.history, iteration, loop.onDelta), }); ``` A turn that ignores `onDelta` behaves exactly as it did before the option existed. | | On a streamed turn | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | The turn's result | Unchanged - the complete result, and the value a replay reads | | Tool calls | Unchanged - the visible text streams while the turn's tool calls arrive intact | | The timeline | One `ai_chunk` frame per delta, keyed to the turn's own durable step: turn 0 of `triage` streams under `triage:iter:0` | | The turn's journal | Gains `stream: true` and `ttftMs`, so per-turn time to first token is on the record; the completion text stays out of the journal, as on any AI step | | A replayed turn | Streams nothing - the memoized turn is returned without calling the model again | Both built-in providers stream (`anthropic` and `aisdk`), and an adapter that implements no `stream` falls back to a plain call, so `stream: true` degrades rather than failing. It is opt-in for what it costs: a timeline row per delta, and the model's completion text on the timeline. An agent that only needs the durable record should not pay for either. ## On the timeline Streamed deltas ride the same per-run timeline as status transitions and logs, as an `ai_chunk` frame [kind](/core/realtime#watching-a-run): | `kind` | Fields beyond `seq` / `ts` / `runId` | | ---------- | ---------------------------------------------- | | `ai_chunk` | `step`, `attempt`, `index`, `delta`, `ttftMs?` | Tail them with `runs.watch` and reconstruct the text by concatenating deltas in `index` order: ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); let text = ""; for await (const frame of duraton.runs.watch(runId)) { if (frame.kind === "ai_chunk" && frame.step === "summarize-thread") { text += frame.delta; if (frame.ttftMs !== undefined) console.log("time to first token:", frame.ttftMs, "ms"); } } ``` `ttftMs` (time to first token) rides only the **first** delta of a stream, so you can surface latency the moment generation begins. `index` is a per-stream counter; frames are appended in order and each carries the run's monotonic `seq`, so the [lossless reconnect](/core/realtime#resuming-after-a-drop) rules apply unchanged - resume past the last `seq` you saw and you never miss or double-count a delta. ## Resumability Because every delta is a durable row, the stream is **replayable**, not ephemeral: * A viewer that opens the run *after* generation started replays every delta from token 0, rebuilds the full text, then tails the rest live. * A refresh mid-stream loses nothing: `runs.watch` replays the history, so the text reconstructs exactly. * Deltas are keyed by `(step, attempt)`. If a crash re-runs the step on a later [attempt](/core/retries), its stream carries a new `attempt`, so a resumed stream never mixes with the abandoned one - render only the latest attempt's deltas. On [replay](/core/durable-execution), the generate step is memoized from its recorded result and returns the complete text without calling the model again - so a replay does not re-stream, and there is no re-spend on a recorded call. ## In React `@duraton/react` is the headless-hooks package behind the Duraton console, not something you install - it exposes a `useStream` hook that does the reconstruction above for you, riding the same durable timeline described in [the client reference](/reference/sdk/client), so it is replay-safe and reconnecting by construction. The shape below is illustrative of the pattern, not a copy-paste install target; build the equivalent against the durable timeline directly if you need it in your own app: ```tsx theme={null} import { useStream } from "@duraton/react"; function SummaryStream({ runId }: { runId: string }) { const { text, ttftMs, tokenCount, streaming } = useStream(runId, "summarize-thread"); return (

{text}{streaming && }

{tokenCount} tokens{ttftMs !== undefined && ` · ttft ${ttftMs}ms`}
); } ``` `useStream(runId, step)` returns the reconstructed `text`, the `ttftMs` once the first delta lands, a live `tokenCount`, and a `streaming` flag that stays `true` until the step reaches a terminal status. It picks the latest attempt automatically, so a crash-retried stream renders cleanly. The hook must be used under a `DuratonProvider` holding a [client](/reference/sdk/client). ## In the console A streaming generate step gets a **Stream** tab in the run's step detail. It shows the text building live with a caret, the running token count, and the time to first token; after the step finishes it keeps the reconstructed text (no caret) - the same replay-from-token-0 view the API exposes. See streaming running end to end in the [examples](/start/recipes#make-a-model-call-durable). # Durable execution Source: https://docs.duraton.ai/core/durable-execution Why a long agent picks up where it stopped instead of starting over: each step's result is recorded the moment it completes, and replay skips it. A workflow makes progress by running its handler again and again. Duraton records the result of every step the moment it completes, so each pass replays the finished steps from their recorded results and executes only the next unfinished one. ```ts theme={null} const charge = await ctx.step.run("charge", () => chargeCard(order)); await ctx.step.sleep("settle", "1h"); const ship = await ctx.step.run("ship", () => createShipment(charge)); ``` Interrupt this run after `charge` - a crash, a deploy, an hour of sleep - and the next pass replays `charge` from its recorded result without calling `chargeCard` again, then continues at `ship`. The card is charged exactly once. ## When a step fails Only the failed step retries. Every step before it already has a recorded result, so it is not re-executed. If `charge` succeeds and `ship` throws, Duraton retries `ship` alone (see [Retries](/core/retries)). ## When work must go inside a step The code **between** steps runs on every pass. Only work inside a step is recorded and replayed. Anything with a side effect, or a result that can change between passes - network calls, database reads, randomness, reading the clock - must live inside a step, or it repeats on every pass and its value drifts: ```ts theme={null} const drifts = Date.now(); // re-read on every pass const stable = await ctx.step.run("now", () => Date.now()); // recorded once, replayed after ``` ## The code between steps must be deterministic Given the same recorded step results, the orchestration around your steps - `if` branches, loops, building arguments, choosing step ids - **must** reach the same next step every pass. This is a requirement, not a preference: a branch that takes a different path on a later pass asks for a step that has no recorded result at that position, and the run's recorded history no longer describes the code that produced it. Never branch on a value the handler reads outside a step. `if (Math.random() > 0.5)` and `if (Date.now() > deadline)` decide differently on each pass. Record the value in a step first, then branch on the recorded result. See the **durable execution** example running end to end in [Examples](/start/recipes#retry-a-flaky-call-fail-fast-on-a-bad-one). # Flow Control Source: https://docs.duraton.ai/core/flow-control Shape how a workflow runs under load: concurrency, throttle, rate limit, debounce, batch, priority, singleton, idempotency, plus AI caps and throttles. Flow control shapes *how* a workflow runs under load. Each control is an optional, flat field on the workflow definition, alongside `retry`. A workflow with no flow config runs unshaped - every control is opt-in and off by default. ```ts theme={null} workflow({ name: "sync.account", concurrency: { limit: 5, key: "accountId" }, handler: async (ctx) => { await ctx.step.run("sync", () => syncAccount(ctx.event.data.accountId)); }, }); ``` ## At a glance | Control | Purpose | Acts at | On overflow | | --------------- | ---------------------------------- | ----------------------------- | --------------- | | `concurrency` | Cap simultaneous runs | execution slot | wait + retry | | `throttle` | Cap start rate, smoothly | admission | delay | | `rateLimit` | Cap start rate, shedding | admission | drop | | `debounce` | Collapse a burst to its last event | admission | coalesce | | `batch` | Fold many events into one run | admission | collect | | `priority` | Jump the shared queue | admission | re-order | | `singleton` | One run at a time per key | admission | skip or cancel | | `idempotency` | One run per key within a window | admission | drop (deduped) | | `cap` | Ceiling on a run's AI spend | each `step.ai` call | halt + fail run | | `tokenThrottle` | Cap a scope's AI token rate | admission (debited post-step) | delay the start | ## Keys Most controls take an optional `key`: a dotted path into the event data that scopes the control to a value. `key: "accountId"` gives each account its own independent limit; `key: "user.id"` reads a nested field. Keys are field paths, not expressions. An omitted key - or a missing / non-scalar field - scopes the control to the whole workflow. ## Concurrency Caps how many runs execute **at once** in a scope. A slot is held only while a run is actively executing, so a run that is sleeping or awaiting an event releases its slot and does not consume one. The count is taken across the shared database, so the limit is **global**, not per-process. Over-limit runs are not dropped - they wait and retry as slots free, preserving order. ```ts theme={null} concurrency: { limit: 5, key: "accountId" } ``` | Property | Type | Default | Description | | -------- | -------- | -------------- | ------------------------------------------------------ | | `limit` | `number` | required | Max runs executing simultaneously in the scope. | | `key` | `string` | whole workflow | Event-data path; each value gets an independent limit. | ### Project-wide ceiling Above the per-workflow `concurrency` you set in code, each project has a **concurrency ceiling** that caps how many runs execute at once across the *whole* project, regardless of workflow or key. It comes from your plan, not from a workflow field (`0` = unlimited). A run must clear both its per-workflow limit and the project ceiling to start; whichever is tighter applies, and an over-ceiling run waits and retries exactly like a per-workflow over-limit run. ```sh theme={null} curl "$DURATON_URL/flow-state" | jq .concurrency # { "limit": 50, "inUse": 12 } ``` ## Throttle Bounds how often runs **start**, smoothing bursts by spreading overflow into the future - one start every `perMs / limit`. No run is lost; excess runs begin later. ```ts theme={null} throttle: { limit: 100, perMs: 60_000, key: "customer" } ``` | Property | Type | Default | Description | | -------- | ------------- | -------------- | ---------------------------------------------- | | `limit` | `number` | required | Max starts per window. | | `perMs` | `number` (ms) | required | Window length. | | `key` | `string` | whole workflow | Event-data path; each value gets its own rate. | ## Rate limit Same window as throttle, opposite action: instead of delaying overflow it **drops** it. Up to `limit` runs start per `perMs`; the rest are shed and the event response reports `dropped: true`. Use it for abuse protection where shedding beats queueing. ```ts theme={null} rateLimit: { limit: 1000, perMs: 60_000, key: "ip" } ``` | Property | Type | Default | Description | | -------- | ------------- | -------------- | ---------------------------------------------- | | `limit` | `number` | required | Max starts admitted per window. | | `perMs` | `number` (ms) | required | Window length. | | `key` | `string` | whole workflow | Event-data path; each value gets its own rate. | Throttle and rate limit share one rate primitive (GCRA). Throttle delays the overflow; rate limit drops it. ## Debounce Coalesces a burst of events into a single run that fires after `periodMs` of quiet. Each new event slides the deadline forward and replaces the payload, so only the **last** event in a quiet-bounded burst runs. ```ts theme={null} debounce: { periodMs: 5_000, key: "documentId" } ``` | Property | Type | Default | Description | | ---------- | ------------- | -------------- | ---------------------------------------------------- | | `periodMs` | `number` (ms) | required | Quiet gap after the last event before the run fires. | | `key` | `string` | whole workflow | Event-data path; each value debounces independently. | ## Batch Collects events into **one** run, flushing when the buffer hits `maxSize` **or** `timeoutMs` elapses, whichever comes first. The run receives the events as `ctx.events`; `ctx.event` is the first of them. ```ts theme={null} workflow({ name: "index.documents", batch: { maxSize: 100, timeoutMs: 5_000, key: "index" }, handler: async (ctx) => { for (const e of ctx.events ?? []) { await ctx.step.run(e.data.id, () => index(e.data)); } }, }); ``` | Property | Type | Default | Description | | ----------- | ------------- | -------------- | ----------------------------------------------- | | `maxSize` | `number` | required | Flush once this many events are buffered. | | `timeoutMs` | `number` (ms) | required | Flush this long after the first buffered event. | | `key` | `string` | whole workflow | Event-data path; each value batches separately. | ## Priority Shifts a workflow's runs **earlier** in the shared queue by `shiftMs`, so they dequeue ahead of other workflows competing for the same slots. ```ts theme={null} priority: { shiftMs: 60_000 } ``` | Property | Type | Default | Description | | --------- | ------------- | -------- | ----------------------------------------------- | | `shiftMs` | `number` (ms) | required | Treat runs as if enqueued this many ms earlier. | ## Singleton Allows at most **one** non-terminal run per key. ```ts theme={null} singleton: { key: "accountId", mode: "skip" } ``` | Property | Type | Default | Description | | -------- | ---------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `key` | `string` | whole workflow | One concurrent run per value. | | `mode` | `"skip"` \| `"cancel"` | `"cancel"` | On collision: `skip` drops the new trigger (response reports `skipped: true`); `cancel` cancels the running run and starts the new one. | ## Idempotency Suppresses a **second run of this workflow** for the same derived key within a time window. The first matching event starts a run; a later event whose key resolves to the same value inside the window is dropped for this workflow (the response reports `deduped: true` with no `runId`). The event is still recorded and still wakes `waitForEvent` waiters - only the duplicate **run** is suppressed. ```ts theme={null} idempotency: { key: "orderId", periodMs: 86_400_000 } // at most one run per orderId per 24h ``` | Property | Type | Default | Description | | ---------- | ------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------- | | `key` | `string` | whole workflow | Event-data path; one run per value within the window. An omitted key means one run per window for the whole workflow. | | `periodMs` | `number` (ms) | `86_400_000` (24h) | How long a key stays claimed before it can run again. | The `key` is a dotted field path into the event data, resolved the same way as [every other control](#keys). A structurally malformed path - empty segments, or a leading or trailing dot - is rejected when the workflow is registered. When the path names a field the event does not carry, or the value at it is not a scalar (string, number, or boolean), the key resolves to the shared workflow-wide window - so a mistyped path silently stops per-key deduplication and folds distinct events into a single window. To verify a per-key path resolves, send two events with **distinct** payloads and confirm two runs start. Sending the same payload twice is deduped whether or not the path resolves, so it proves nothing. This is run-level dedupe keyed off the event payload. To dedupe a whole event regardless of which workflows it matches - the usual safety net for an at-least-once caller retrying `POST /events` - send a `dedupeId` on the event instead (see [Events](/reference/api/events)); a repeat of that id within 24h is dropped before any fan-out. ## AI spend controls Two more controls are declared the same way but meter **AI spend** rather than run starts. They are documented with the rest of the spend tooling in [Cost controls](/ai/cost-controls); the table is the short version. | Control | Bounds | When crossed | Resumes | | --------------- | --------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------- | | `cap` | One run's AI spend (`maxCost`, `maxTokens`) | Halts **before** the `step.ai` call that would cross it; the run fails with `BudgetError` | Replay under a raised cap | | `tokenThrottle` | Token **rate** per key (`tokens` per `perMs`) | Delays the key's next run starts; nothing fails or pauses | When the rate recovers | ## Observing flow control ```sh theme={null} curl "$DURATON_URL/flow-state?workflow=index.documents" ``` ```json theme={null} { "debounce": [{ "app": "docs", "workflow": "index.documents", "pending": 3, "nextFireAt": "2026-07-01T10:00:05Z" }], "batch": [{ "app": "docs", "workflow": "index.documents", "buffered": 42, "oldestAt": "2026-07-01T10:00:01Z" }], "concurrency": { "limit": 50, "inUse": 12 } } ``` | Surface | What it carries | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET /flow-state` | The live debounce and batch backlogs per workflow (`?app=` and `?workflow=` narrow the scope), plus the project's concurrency ceiling and current draw under `concurrency` (`limit`, `0` = unlimited, and `inUse`). | | `GET /workflows` | Each workflow's configured controls, on its `flowControl` field. | | `GET /runs/stats` | In-flight and queued counts (`active`, `queued`, `running`). | The console renders the same three reads in the Workflows list and on Overview. # Durable runs Source: https://docs.duraton.ai/core/index The durable runtime under every Duraton agent and job: workflows, steps, triggers, retries, flow control, runners, and the live run record. This page is for the code path - the durable runtime underneath, in TypeScript. To build an agent without code, start with [Build your first agent](/start/first-agent). Everything in Duraton - an AI agent, a nightly job, a webhook handler - is a **workflow**: an ordinary TypeScript function whose units of work are wrapped in **steps**. Duraton records each step's result the moment it completes. That one rule is what the rest of the platform is built on: a crash, restart, or deploy resumes the run at the next step instead of starting over; a step that waits on a person or a timer holds no worker while it waits; and the record of what ran, in what order, with what result, exists before anyone asks for it. This tab is that runtime. It reads the same whether or not there is a model call in the handler - the AI-specific steps are in [AI](/ai), and the agent authoring layer in [Agent kit](/agent-kit). ## The pieces | Piece | What it is | Read | | ------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------- | | **Workflow** | A named function, triggered by an event, a schedule, or by hand. | [Workflows](/core/workflows) | | **Step** | One durable unit inside a handler. Runs once, result recorded, retried on its own. | [Steps](/core/steps) | | **Run** | One execution of one workflow, with its own status, steps, logs, and output. | [Watching a run](/core/realtime) | | **Event** | The message that starts a run. Persisted, and can fan out to many workflows. | [The event log](/core/workflows#the-event-log) | | **Runner** | Your process, holding your workflow code. It dials out with `connect()`. | [Runners](/core/runners) | | **App** | The name a runner registers under; workflows are addressed by name + app. | [Workflows](/core/workflows) | | **Project** | The isolated slice: its own runs, events, keys, and runners. | [Projects](/core/projects) | ## What the runtime gives a handler | Capability | Surface | Read | | ---------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | Durable work | `step.run`, `step.sleep`, `step.sleepUntil`, `step.waitForEvent`, `step.poll`, `step.runWorkflow`, `step.emit` | [Steps](/core/steps) | | Parallel work | `Promise.all` over steps; each branch is its own step | [Steps](/core/steps#parallel-steps) | | Failure handling | Per-step retries, `NonRetriableError`, `RetryAfterError`, an `onFailure` handler | [Retries](/core/retries) | | Triggers | Event triggers with filters and wildcards, cron schedules, manual runs | [Triggers](/core/triggers) | | Flow control | `concurrency`, `throttle`, `rateLimit`, `debounce`, `batch`, `priority`, `singleton`, `idempotency` | [Flow control](/core/flow-control) | | Run control | Cancel, pause, resume, replay, retry from a step - each recorded with the actor behind it | [Control API](/reference/api/control) | | Live record | `ctx.log`, the streaming run timeline, the project-wide status stream | [Logging](/core/logging), [Watching a run](/core/realtime) | A step id (`"triage"`, `"refund"`) is how its saved result is found on the next pass. Keep ids stable and unique within a handler, or a replay will not match the work it already did. [Steps](/core/steps#step-ids) covers the rules, and [Production](/core/production#step-ids-across-deploys) covers changing them safely. ## Next steps What a step guarantees, and the two rules the code between steps has to follow. Create a project, connect a runner, trigger a run, and restart the runner mid-run. One complete, paste-and-run workflow per task. Routing, deploying with runs in flight, draining, and blue/green. # Logging Source: https://docs.duraton.ai/core/logging See what your agent did, per run: ctx.log records structured logs that Duraton captures, keeps durable under replay, and shows against the run. `ctx.log` records structured, leveled logs from inside a workflow. Unlike a bare `console.log` - which stays on the runner's stdout, invisible to Duraton - `ctx.log` lines flow to Duraton, persist durably with the run, and are readable per run via the [API](/reference/api/runs#run-logs). ## Logging a line `ctx.log` is callable (info level) and has one method per level: ```ts theme={null} const ticketCreated = workflow({ name: "ticket.created", handler: async (ctx) => { ctx.log.info("ticket received", { ticketId: ctx.event.data.ticketId }); const triage = await ctx.step.run("triage", async () => { ctx.log.info("triaging ticket", { priority: "high" }); return triageTicket(ctx.event.data); }); ctx.log.warn("triaged, refunding next", { refundId: triage.id }); }, }); ``` | Call | Level | | --------------------------------- | ------- | | `ctx.log(message, fields?)` | `info` | | `ctx.log.debug(message, fields?)` | `debug` | | `ctx.log.info(message, fields?)` | `info` | | `ctx.log.warn(message, fields?)` | `warn` | | `ctx.log.error(message, fields?)` | `error` | `fields` is an optional object of structured context. It is stored as JSON, so prefer structured fields over interpolating values into the message. ## Durable under replay A handler re-runs from the top on every pass ([durable execution](/core/durable-execution)), so `ctx.log` is replay-aware: * A **handler-level** log (outside any step) re-emits on every pass, but Duraton gives it a stable identity per attempt and records it **exactly once**. * A log **inside a `step.run`** only executes on the pass where that step runs. A step that **retries** records its logs **once per attempt**, so you can see what each attempt did: ```ts theme={null} await ctx.step.run("call-upstream", async () => { ctx.log.info("calling upstream", { attempt: ctx.attempt }); const res = await callUpstream(); if (!res.ok) { ctx.log.warn("upstream failed, will retry"); throw new Error("upstream error"); } return res; }); ``` ## Redaction Field values under sensitive key names (`password`, `token`, `secret`, `authorization`, and similar) are masked to `[redacted]` before anything is persisted. Redaction is keyed on field **names**, so prefer putting sensitive values in named fields rather than inlining them into the free-text `message`. ## Reading logs back Fetch a run's logs oldest-first: ```sh theme={null} curl "$DURATON_URL/runs//logs" ``` Each line carries its `level`, `message`, `fields`, `scope` (the step name, or `@root` for a handler-level log), and `attempt`. See the [Runs API](/reference/api/runs#run-logs) for pagination and the full response shape. ## Limits Each pass caps how many lines it ships so a pathologically chatty handler cannot overrun the 1 MiB wire-message limit; beyond the cap, a single line records how many were dropped. Logs are part of a run's data and are removed with the run. See the **logging** example running end to end in [Examples](/start/recipes#log-then-watch-a-run-live). # Production and operations Source: https://docs.duraton.ai/core/production Deploy new agent code without stranding runs in flight: how many runners to run, pin vs anycast routing, graceful shutdown, and the step-id footgun. Duraton holds the durable state; your **runners** are stateless processes that execute steps. That split is what makes production operations simple - a runner can crash, restart, or be redeployed and the run resumes from its last checkpoint - but it leaves a few decisions to you: how many runners to run, how to route to them, and how to roll out new code without stranding runs that are mid-flight. ## How many runners, and how to route An app can register **many runners**; each is one process (one replica). A run is owned by an app and executed by one of that app's live runners. The routing handle is the `runner` id on the event: | | **Anycast** (no `runner` on the event) | **Pinned** (`runner` set on the event) | | -------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------- | | Where it runs | Any one live runner of the app, picked per invoke | Only the named runner id | | Scaling | Horizontal - add replicas, Duraton spreads invokes across them | Bound to one instance | | Survives a replica restart | Yes - the next invoke goes to another live replica | Only after that instance is back (parks meanwhile) | | Use for | Stateless work behind a load balancer | Work bound to one instance - a local resource, a specific agent | Because runners are stateless and Duraton **resends the full step memo on every invoke**, different passes of the same anycast run may safely land on different replicas. Scale a stateless fleet horizontally and route anycast; reach for a pin only when a run genuinely needs one specific instance. A pinned run only makes progress while its instance is live. If that instance is down, the run [parks and retries](/core/runners#when-no-runner-is-registered) up to the bounded no-runner wait (default 5 minutes), then fails. Anycast has no such single point of failure as long as one replica is live. ## Deploying new code while runs are in flight A rolling deploy replaces runners one at a time. Because Duraton replays the full memo to whichever runner answers, **a single run can take early passes on the old code and later passes on the new code**. That is fine - and the reason durable execution survives deploys - *provided the step ids the run has already completed still resolve to the same saved results on the new code.* Safe to change in a rolling deploy: * **Adding new steps after the current point.** A run that hasn't reached them yet just discovers them on a later pass. * **Changing the body of a step that hasn't run yet.** Only unexecuted steps pick up new logic. Unsafe in a rolling deploy (see [the footgun](#step-ids-across-deploys) below): * **Renaming, removing, or reordering steps that a run has already executed or is parked on.** * **Changing a workflow's shape** so an already-completed step's id no longer appears. For those, isolate the change - see [blue/green](#blue/green-for-incompatible-changes). ## Graceful shutdown and draining There is no single "drain and exit" call: graceful shutdown means **stop being routed new work, let in-flight work settle, then tear down**. How you do it depends on the transport. `client.ready()` probes **Duraton's** `GET /readyz`, and the `draining: true` it can return means **Duraton** is shutting down - it is not a readiness or drain signal for *your* runner. There is no runner-side `draining` flag; you drive runner shutdown yourself as below. ### Connect runners Call the handle's `close()` on your shutdown signal. It stops the socket's liveness pings and closes the connection. ```ts theme={null} const runner = connect({ app: "shop", runner: "agent-node-1", workflows }); process.on("SIGTERM", () => { runner.close(); // stops liveness, closes the socket }); ``` A clean `close()` **deregisters the endpoint immediately**, so an orderly shutdown has no staleness window to wait out. Any invoke that was in flight over that socket fails with a retriable transport error, so Duraton re-dispatches it: if another replica is live it takes over at once; if this was the last runner the run [parks and retries](/core/runners#when-no-runner-is-registered) until a runner reappears. That immediate eviction is the graceful path only. A connection lost abruptly instead of closed - a hard kill, a network partition - leaves the endpoint listed until it ages out of the [staleness window](/core/runners#liveness) (90 seconds), showing as **Stale** once its last-seen time falls behind. An invoke that lands on it in the meantime fails as a retriable transport error and is re-dispatched, so this costs a retry, not a run. `close()` does **not** wait for in-flight step executions to finish. A step whose function had already started but whose result had not yet been sent back is re-run on the runner that takes over - at-least once for that one uncommitted step (already-completed steps are memoized and never re-run). If you need in-flight work to finish on this instance, stop routing new runs to it and wait before calling `close()`. ## Step ids across deploys This is the classic footgun. A step id is hashed to the key Duraton stores its result under, and the handler re-runs top to bottom on every pass, matching each step to its saved result by that key. **If a step's id changes between the pass that saved its result and a later pass, the later pass finds no saved result and treats the step as new.** The docs' "keep ids stable" rule is really about this: what actually happens when you break it is silent, not a loud error. Concretely, for a run that is in flight when you deploy renamed step ids: * **A completed `step.run` you renamed** is a cache miss on the next pass - so its function **runs again**, repeating its side effect (a second charge, a second email). The old saved result is orphaned. * **A parked `sleep` / `waitForEvent` / `approval` you renamed** is worse: the run is parked under the old id, but the new code emits a step under the new id, so Duraton **parks again on the new step** and the old park is orphaned. A `sleep` restarts its full duration; a `waitForEvent` waits again (and an event that had already woken the old wait is discarded); an approval creates a fresh one. The run doesn't crash - it silently re-does the wait, which looks like a hang until a fresh timeout fires. * **Reordering or changing the count of same-id steps** (a loop) shifts the [positional suffixes](/core/steps#reusing-an-id-loops) (`x`, `x:1`, `x:2`, ...), so every later occurrence misses its saved result and re-runs. None of these fail loudly; they duplicate side effects or resurrect waits. The rules that avoid them: * Keep step ids **stable** across a deploy - never rename, remove, or reorder a step that in-flight runs may have already executed or parked on. * Derive loop step ids and iteration order from **already-durable data** so the positional suffixes stay stable across passes. * When a change to step ids or workflow shape is unavoidable, don't roll it out under the same app while runs are in flight - isolate it. ## Blue/green for incompatible changes For a change that would break in-flight runs - renamed or reordered steps, an incompatible workflow shape - don't let old runs hop onto the new code. Isolate the two versions and let the old ones drain: 1. Deploy the new version as a **distinct app** (or, if you pin, a distinct `runner` id) so Duraton treats it as a separate routing target. 2. Cut new events over to the new app/runner. 3. Leave the old fleet running until its in-flight runs reach a terminal state - each one finishes on the same code it started on. 4. Retire the old fleet once it has drained. This trades a brief period of running two versions for the guarantee that no run ever replays across an incompatible code change. For compatible changes (only additive or unexecuted-step changes), a plain rolling deploy under one app is enough. ## Related Connect, pin vs anycast, and no-runner behavior. Step ids, stability, and reusing an id in a loop. Why replay makes a run survive a restart or deploy. Start a run atomically with an app-side database write. # Workspaces & projects Source: https://docs.duraton.ai/core/projects Keep teams and environments apart: a workspace holds members, roles, and one pooled allowance; a project is the isolation boundary for runs, events, and keys. A **workspace** is your top-level grouping: its members, roles, and one pooled plan allowance. A **project** inside it is the isolation boundary - its own runs, events, API keys, [runners](/core/runners), and [webhooks](/integrations/webhooks), invisible to every other project. An API key belongs to one project, so the key a runner or client carries is what puts it inside that project. Issue one on the project's page in the console: ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ apiKey: process.env.DURATON_API_KEY!, // a key issued in that project }); ``` ```sh theme={null} export DURATON_API_KEY="dtn_live_..." ``` There is no cross-project read: an event, run, workflow, or runner in one project cannot be listed, triggered, or resumed with another project's key. ## Switching project The console acts on one **active project** at a time. Switch it from the workspace menu; every view - runs, events, keys, usage - follows the switch. ## Members and roles Roles live at the workspace level, so a member has one role across it: | Role | Can | | ---------- | ---------------------------------------------------------------------- | | **Owner** | Full control of the workspace, including other owners and deleting it. | | **Admin** | Manage projects and members, and read + write across every project. | | **Member** | Read across the workspace's projects; write is granted per project. | A member's write access is scoped by a project list: leave it empty for workspace-wide write, or name projects to limit write to those. ## Usage and limits Duraton meters what your workspace runs and **pools it across the workspace**: usage is tallied per project, but every project draws from one shared plan allowance. Two things are counted: * **Steps** - each durable [step](/core/steps) a run executes. This is the figure your plan is measured against. * **Events** - each event ingested through [`POST /events`](/reference/api/events). Counted for context only; it never affects your allowance. Concurrency - how many runs execute at the same time - is a plan *capability* rather than a monthly tally: it is a per-project ceiling, so each project runs up to the plan's concurrency limit on its own. **Only steps are enforced.** When the workspace's pooled step count reaches the plan limit, Duraton stops starting new runs - and because usage pools, it pauses **every project in the workspace at once**, not just the project that ran the count up. Events and concurrency never trigger a suspension. ### Free plan | Metered | Free plan | | ----------- | ------------------------------------------------------------ | | Steps | 100,000 per calendar month (UTC), pooled across all projects | | Concurrency | 10 simultaneous runs per project | Steps reset at the start of each calendar month; a paused workspace resumes on its own once the new period begins (or once a higher plan raises the ceiling). ### Reading your usage The console's usage view shows the current period's steps and events, a per-project breakdown, and a chart over time, with the pooled total measured against your plan: Per-project usage metered against the workspace plan, in the console The console will warn you as a workspace approaches its step limit, ahead of any project being paused. ### When the limit is reached New runs are refused with [`403 Forbidden`](/reference/api/limits#api-rate-limits) until usage falls back under the plan or the plan is raised. In-flight waiters still wake; only new runs are refused. A [triggered event](/reference/api/events) is still accepted (`202`) and comes back with `suspended: true` and no `runId`: ```json theme={null} { "woke": 0, "suspended": true } ``` # Watching a run Source: https://docs.duraton.ai/core/realtime Watch a run as it happens instead of polling: runs.watch tails status transitions and logs over a durable, resumable per-run timeline. Every run has a durable **timeline**: an append-only, ordered record of its status transitions and [`ctx.log`](/core/logging) lines. `runs.watch` streams it live, so you follow a run as it executes instead of polling `GET /runs/{id}` in a loop. ## Watching a run `runs.watch(id)` is an async generator of timeline **frames**. It replays the run's history on connect, tails new frames as they land, and ends on its own when the run reaches a terminal state. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); for await (const frame of duraton.runs.watch(runId)) { switch (frame.kind) { case "run_status": console.log("run ->", frame.status); break; case "step_status": console.log(" step", frame.name, "->", frame.status); break; case "log": console.log(" log", frame.level, frame.message); break; } } ``` A frame is a discriminated union on `kind`; narrowing on `kind` gives you the right fields: | `kind` | Fields beyond `seq` / `ts` / `runId` | | ------------- | ------------------------------------------------------------------------------- | | `run_status` | `status`, `currentStepName?`, `attempt?` | | `step_status` | `name`, `status`, `attempt`, `index?`, `error?` | | `log` | `level`, `message`, `fields?`, `scope`, `attempt` | | `ai_chunk` | `step`, `attempt`, `index`, `delta`, `ttftMs?` - see [Streaming](/ai/streaming) | Status transitions and log lines append to that one stream, ordered by a per-run `seq`. Each transition's timeline row is written in the same transaction as the status change, so the stream cannot disagree with the run. ## Reading log history To read the persisted `ctx.log` lines without opening a stream - a one-shot dump, a report, paging a long run - use `runs.logs`: ```ts theme={null} const lines = await duraton.runs.logs(runId); // oldest first const more = await duraton.runs.logs(runId, { from: lines.at(-1)?.seq, limit: 200 }); ``` Each line carries `seq`, `ts`, `level`, `message`, `fields?`, `scope`, and `attempt`. Pass the last `seq` you saw as `from` to page forward. This is the same data the `log` frames carry, served as history from [`GET /runs/{id}/logs`](/reference/api/runs#run-logs). ## Resuming after a drop Every frame carries a monotonic `seq`. Remember the last one you saw and reconnect past it: ```ts theme={null} let last = 0; for await (const frame of duraton.runs.watch(runId, { from: last })) { last = frame.seq; // ...handle frame } ``` `{ from }` replays strictly after that `seq` - no gaps, no duplicates. ## How it streams `runs.watch` opens a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) connection to `GET /runs/{id}/stream` over native `fetch` - no extra dependency. It replays every row after `?from=`, then delivers new rows as they commit. A frame is published only **after** its row commits, so nothing is pushed that is not already durable. The live push is a latency optimization over that durable record, not the record itself: a push that never arrives has not lost the row, and the next read or `{ from }` reconnect returns it. What a dropped frame costs you is push latency, not a frame. ## The project-wide stream `runs.watchAll` (`GET /runs/stream`) tails `run_status` transitions across the whole project - the read behind a live runs list. It is **best-effort**: there is no global cursor across runs, so it tails by timestamp and a frame is a "refetch" signal rather than a lossless log; a missed frame self-corrects on the next transition. ```ts theme={null} for await (const frame of duraton.runs.watchAll()) { console.log(frame.runId, "->", frame.status); // a run_status frame; pass { signal } to stop } ``` Use [`runs.watch`](#watching-a-run) when you need one run's exact, ordered timeline, and `runs.watchAll` when you only need to know the project had activity. ## Filtering the project-wide stream `runs.watchFiltered` narrows the firehose to only the `run_status` frames matching a filter, so a multi-tenant view subscribes to exactly the slice it renders instead of receiving every run's transitions and filtering client-side. It hits the same `GET /runs/stream` endpoint with query params, and keeps the same best-effort semantics as `watchAll` (each frame is a refetch signal, not a lossless log). The filter axes are the same as [`runs.list`](/reference/api/runs), combined with **AND**: | Param | Query | Matches | | ---------- | ------------------------------- | --------------------------------------------------- | | `app` | `?app=` | Runs in one app | | `workflow` | `?workflow=` | Exact workflow name | | `status` | `?status=` | Runs currently in one [status](/reference/api/runs) | | `tags` | repeatable `?tag.=` | Runs carrying **all** of these tag pairs | Omitting every axis is equivalent to `runs.watchAll`. ```ts theme={null} // Only running charges for one tenant, live. for await (const frame of duraton.runs.watchFiltered({ app: "billing", workflow: "charge", status: "running", tags: { tenant: "acme" }, })) { console.log(frame.runId, "->", frame.status); // pass { signal } to stop } ``` Any client can use the endpoint directly with the query params above. Status is read from the frame itself; `app`, `workflow`, and `tags` are run attributes the engine resolves once per run id and caches for the life of the connection (a run's app, workflow, and tags are fixed at creation), so a busy workspace costs one lookup per distinct run, not per frame. ### Backpressure and fanout Each subscriber is served by its own connection with a **bounded** send buffer; a subscriber that falls behind drops frames rather than growing an unbounded queue, so one slow client can never stall the engine or another subscriber. Because the durable timeline is the source of truth, a dropped push is only lost latency: the next transition (or a periodic refetch) re-surfaces the run's current state. Fanout cost scales with the number of subscribers times the transition rate; the server-side filter keeps each subscriber's *delivered* bytes proportional to its slice, not the whole project. Validating fanout under real concurrency (many subscribers, high transition rate) is a **deploy-time** step against a running engine with a load harness - it is not exercised by the unit/integration tests, which cover the filter matching and the slow-consumer drop policy in isolation. ## Durable transitions across the project `runs.watchAll` and `runs.watchFiltered` are deliberately lossy: they carry only `run_status` frames and treat each one as a *refetch signal*, so they are perfect for a live list but useless if you need to observe every step of every run without missing one. `runs.watchTransitions` is the durable counterpart - the project-wide, **step-level**, resumable transition log. It is the managed equivalent of an in-process `onStepTransition` hook, except it survives a reconnect. It hits the same `GET /runs/stream` endpoint as `watchAll`/`watchFiltered`, with two additions: * **`kinds`** - which transition kinds to receive. The SDK method defaults to **both** `run_status` and `step_status` (`TRANSITION_KINDS`), sent as `?kinds=run_status,step_status`. Only these two kinds are carried project-wide; `log` and `ai_chunk` stay on the per-run [`runs.watch`](#watching-a-run) to keep the firehose bounded. (The raw endpoint defaults to `run_status` only when `?kinds=` is omitted, which is why `watchAll` is unaffected - it never sends the param.) * **`since`** - an RFC3339 timestamp cursor. The stream resumes from that position instead of tailing from connect time, so a reconnect picks up exactly where you left off. Omit it to tail from now. It also takes the same optional `app` / `workflow` / `status` / `tags` narrowing axes as [`watchFiltered`](#filtering-the-project-wide-stream) (AND across tags). ### At-least-once, dedupe on `(runId, seq)` Unlike the per-run [`runs.watch`](#watching-a-run), there is no single global cursor across runs - the engine tails the workspace timeline by timestamp and re-scans a short overlap window on each poll, because rows can commit slightly out of order. That makes delivery **at-least-once**: on a reconnect (and around the `since` boundary) a frame you already saw can arrive again. Combine each frame's `runId` with its per-run `seq` into an idempotency key and dedupe on it: ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); const seen = new Set(); let cursor: string | undefined; // persist this to resume across restarts for await (const frame of duraton.runs.watchTransitions({ kinds: ["run_status", "step_status"], // the default; narrow to one if you like since: cursor, app: "billing", // optional: same axes as watchFiltered })) { const key = `${frame.runId}:${frame.seq}`; if (seen.has(key)) continue; // at-least-once -> dedupe seen.add(key); cursor = frame.ts; // an RFC3339 ts; feed back as `since` on reconnect switch (frame.kind) { case "run_status": console.log(frame.runId, "run ->", frame.status); break; case "step_status": console.log(frame.runId, "step", frame.name, "->", frame.status); break; } } ``` Any client can use the endpoint directly with `?kinds=run_status,step_status&since=` plus the optional filter params. ### Bounded replay: `since` has a max lookback A `since` cursor cannot resume from arbitrarily far back. The stream bounds how much history it will replay, so a stale or wrong cursor - a process that reconnects after a long outage still holding an old persisted `since` - cannot silently replay weeks of history as if it just happened and re-trigger every side effect on the way. | | | | ------------------------------------ | ----------------------------------------------------------------------------- | | Maximum lookback | `86400000` ms (24h) | | When `since` is older than the bound | clamped to `now - maxLookback` - never rejected outright | | Signal | a `cursor_clamped` frame, sent once, before any other frame on the connection | The clamp is never silent. When `since` predates the bound, the very first frame on the connection names both the cursor you asked for and the one the stream actually resumed from: ```ts theme={null} for await (const frame of duraton.runs.watchTransitions({ since: cursor })) { if (frame.kind === "cursor_clamped") { console.warn( `cursor ${frame.requestedSince} predates the ${frame.maxLookbackMs}ms bound; ` + `resumed from ${frame.effectiveSince} instead - some history was skipped`, ); continue; } // ... } ``` A cursor this stale usually means the consumer was down far longer than expected, or persisted a `since` from the wrong stream. Deeper history is still reachable - the bound applies only to the live, resumable firehose - by paging a specific run's timeline with [`runs.logs`](#reading-log-history) or reading run state directly with `runs.list`/`runs.get`. ### Catch-up vs live: the `historical` flag Every `run_status` and `step_status` frame from the project-wide stream carries `historical`: `true` while the frame is catch-up replay from your `since` cursor, `false` once the stream has caught up to "now" as of connect time. It flips at most once per connection, from `true` to `false`, and stays `false` for the rest of the stream. | `historical` | Meaning | | ------------ | --------------------------------------------------------------------------------- | | `true` | Replay of a transition that already happened before you connected | | `false` | The transition happened after you connected - this is live | | absent | [`runs.watch`](#watching-a-run) (the per-run stream) - it has no catch-up concept | Use it to keep local state current from every frame while limiting side effects to genuinely new activity: ```ts theme={null} for await (const frame of duraton.runs.watchTransitions({ since: cursor })) { if (frame.kind === "cursor_clamped") continue; applyToLocalState(frame); // always keep local state current, replay included if (frame.historical) continue; // already handled the first time it happened notifySlack(frame); // side effects only for frames that are actually live } ``` `historical` is a flag on every frame, not a one-shot "caught up" marker frame. Delivery here is already at-least-once (see below), so a stateless per-frame flag survives a dropped push or a mid-catch-up reconnect the same way `(runId, seq)` dedup does; a single boundary event could be missed on a drop and never resent. ### watchFiltered vs watchTransitions Both narrow the same `GET /runs/stream` endpoint; they differ in what a frame *means*: | | [`runs.watchFiltered`](#filtering-the-project-wide-stream) | `runs.watchTransitions` | | --------------- | ---------------------------------------------------------- | ------------------------------------------------------------- | | Frame kinds | `run_status` only | `run_status` + `step_status` (`kinds` opt-in) | | Resume | none - tails from connect | `since` (RFC3339) cursor, bounded by the 24h maximum lookback | | Delivery | best-effort refetch signal | at-least-once transition log | | On reconnect | may miss transitions; self-corrects on the next one | replays from `since`; you dedupe on `(runId, seq)` | | Catch-up marker | n/a - always live | `historical` flag per frame until caught up | | Use it for | a live list / overview that refetches | reacting to every step of every run without loss | Reach for `watchFiltered` when a frame just tells you "something changed, refetch". Reach for `watchTransitions` when you must act on each transition and cannot afford to drop one across a reconnect. ## In the console The run-detail view tails `GET /runs/{id}/stream`, so status and steps update live; a step's `ctx.log` lines appear under a **Logs** tab on that step, and handler-level lines under a **Logs** tab on the run. # Retries & failure handling Source: https://docs.duraton.ai/core/retries Survive a flaky call without losing the run - only the failing step retries, and onFailure handlers plus replay cover the ones that run out. When a step throws, Duraton retries that step - not the whole workflow. Steps that already succeeded keep their recorded results and are not re-executed. A workflow does not retry unless it opts in with `retry`. ```ts theme={null} const ticketCreated = workflow({ name: "ticket.created", retry: { maxAttempts: 3 }, handler: async (ctx) => { await ctx.step.run("triage", () => triageTicket(ctx.event.data)); }, }); ``` | Property | Type | Default | Description | | ---------------- | -------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `maxAttempts` | `number` | `1` | Total times a step may run, including the first. `1` means the step runs once and a throw fails the run; `3` means one run plus up to two retries. | | `backoff` | `"fixed" \| "linear" \| "exponential"` | `"fixed"` | How the delay between attempts grows. `fixed` is constant; `linear` is `initialDelay * attempt`; `exponential` is `initialDelay * 2^(attempt-1)`. | | `initialDelayMs` | `number` | `1000` | The base delay before the second attempt (and the unit the backoff shape multiplies). | | `maxDelayMs` | `number` | `30000` | An upper bound the computed delay is capped at, so exponential backoff cannot grow without limit. | ## What happens on failure 1. A step throws. 2. Duraton waits the backoff delay, then runs that one step again. 3. This repeats until the step succeeds or reaches `maxAttempts`. 4. If the step fails on its last attempt, the run fails. ## Backoff By default Duraton waits **1000 ms** between step attempts, the same before every attempt. Set `backoff` to shape how the delay grows with each attempt, `initialDelayMs` to change the base delay, and `maxDelayMs` to cap it: ```ts theme={null} const syncInventory = workflow({ name: "inventory.sync", // Back off 1s, 2s, 4s, 8s... capped at 30s, over five attempts. retry: { maxAttempts: 5, backoff: "exponential", initialDelayMs: 1000, maxDelayMs: 30000 }, handler: async (ctx) => { await ctx.step.run("pull", () => pullInventory()); }, }); ``` To override the delay for a single attempt at runtime (for example honoring an upstream `429`'s `Retry-After`), throw [`RetryAfterError`](#retryaftererror) with the delay you want. Two other delays exist and are not this one: | Retry | Shape | Where | | ------------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | | Step retry | `backoff` shape between attempts (fixed 1000 ms by default), up to `maxAttempts`. | This page. | | Runner unreachable | Fixed 1000 ms between attempts, 3 attempts, then the run fails. | [Transport errors](#transport-errors) below. | | Outbound webhook delivery | Exponential backoff, a separate attempt budget. | [Webhooks](/integrations/webhooks) - a different subsystem, not step retries. | ## Per-step retry A workflow's `retry` sets the default for every step. Pass a `retry` option to a single `ctx.step.run` to override it for that step alone - useful for a polling step that needs many short attempts without forcing that budget onto the rest of the run: ```ts theme={null} const deployApp = workflow({ name: "app.deploy", retry: { maxAttempts: 3 }, handler: async (ctx) => { await ctx.step.run("dispatch", () => dispatchDeploy()); // Poll for readiness independently: up to 30 attempts, 5s apart. await ctx.step.run("verify-tls", () => checkTls(), { retry: { maxAttempts: 30, backoff: "fixed", initialDelayMs: 5000 }, }); }, }); ``` The step's own policy governs its attempt budget and backoff; every other step keeps the workflow default. The per-step `retry` option and configurable `backoff` are available in the TypeScript SDK. ## Controlling retries from a step Two error types let a step override the default retry behavior. Import them from the Duraton SDK. ### NonRetriableError Fail the run immediately with `NonRetriableError`, skipping any remaining attempts. Use it for failures retrying cannot fix, such as a validation error or missing configuration. ```ts theme={null} import { NonRetriableError } from "@duraton/sdk"; await ctx.step.run("validate", () => { if (!apiKey) throw new NonRetriableError("missing API key"); }); ``` ### RetryAfterError Retry after a delay you choose with `RetryAfterError` instead of the policy's configured backoff, for example honoring an upstream `429`'s `Retry-After`. ```ts theme={null} import { RetryAfterError } from "@duraton/sdk"; await ctx.step.run("call-upstream", async () => { const res = await fetch(url); if (res.status === 429) throw new RetryAfterError("rate limited", "30s"); return res.json(); }); ``` | Argument | Type | Description | | ------------ | -------------------------- | --------------------------------------------------------------------------------------------------------- | | `message` | `string` | The error message recorded on the failed attempt. | | `retryAfter` | `string \| number \| Date` | When the next attempt runs: a duration string (`"30s"`), a number of milliseconds, or an absolute `Date`. | `RetryAfterError` does **not** grant extra attempts - it only changes *when* the next attempt runs. Once the step reaches `maxAttempts` the run fails as usual. ## Transport errors If Duraton cannot reach your runner at all (the process is down, or it returns a server error), the invoke is retried on its own budget: **3 attempts, 1000 ms apart**, independent of `maxAttempts`. A network blip does not consume a step's retry budget. After the third failed invoke, the run fails. ## onFailure Declare an `onFailure` handler to run compensation or notification logic when a run fails. It fires once the run has exhausted its retries (or hit a non-retriable error) and been marked `failed`, and it receives the original event plus `ctx.error`. ```ts theme={null} const capturePayment = workflow<{ paymentId: string }>({ name: "payment.capture", retry: { maxAttempts: 3 }, handler: async (ctx) => { await ctx.step.run("capture", () => capture(ctx.event.data.paymentId)); }, onFailure: async (ctx) => { await ctx.step.run("void-hold", () => voidHold(ctx.event.data.paymentId)); }, }); ``` | Property | Type | Description | | ----------- | --------------------- | -------------------------------------------------------------------- | | `ctx.error` | `{ message, stack? }` | The terminal error that failed the run. Set only inside `onFailure`. | `onFailure` is itself a durable execution: its steps are recorded and retried like any handler. It **cannot un-fail the run** - the failed run stays failed. Use it to compensate (release a hold, reverse a write) or to notify, not to retry the work. ## Failed runs A run is marked `failed` only once it is terminal and out of retries - a run still retrying stays non-terminal - so the set of `failed` runs is exactly the set of runs that permanently failed, each carrying its terminal error. ```sh theme={null} curl "$DURATON_URL/runs?status=failed" -H "Authorization: Bearer $DURATON_API_KEY" ``` | Action | Endpoint | Description | | ------------------ | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | List | [`GET /runs?status=failed`](/reference/api/runs) | Every permanently failed run. | | Inspect | [`GET /runs/{id}`](/reference/api/runs) | The run with its terminal error. | | Replay | [`POST /runs/{id}/replay`](/reference/api/control#replay-semantics) | A fresh run from the same trigger, from the first step. | | Replay from a step | [`POST /runs/{id}/retry-from-step`](/reference/api/control#retry-from-a-step) | A fresh run that carries the completed steps before the chosen one, so the work that already succeeded is not repeated. | | Replay in bulk | [`POST /runs/bulk-replay`](/reference/api/control#bulk-replay) | Replays every run matching a filter. | In the console, failed runs appear in the **Runs** view with their failure reason inline; the **Failed** stat tile filters the list to them in one click. See the **error handling** example running end to end in [Examples](/start/recipes#retry-a-flaky-call-fail-fast-on-a-bad-one). # Runners Source: https://docs.duraton.ai/core/runners Run your workflow code wherever it already lives: the runner dials out to Duraton over a WebSocket, so it needs no inbound address. A **runner** is a process that hosts an app's workflows and executes its steps. An app can have many runners. Each one reaches Duraton with [`connect`](/reference/sdk/connect). ## Connect (outbound WebSocket) Your runner dials Duraton and receives invokes over a persistent WebSocket, so it needs **no inbound address** and works behind NAT, a firewall, or inside a container with no ingress. ```ts theme={null} import { connect } from "@duraton/sdk"; import { workflows } from "./workflows"; connect({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY!, app: "support", runner: "agent-node-1", workflows, }); ``` No HTTP server and no `register` call: `connect` handshakes, registers the workflow manifest over the socket, answers invokes, and reconnects if the socket drops. There is no heartbeat to configure - Duraton heartbeats the socket itself and keeps the endpoint fresh from that (see [liveness](#liveness)). It authenticates with your API key on the socket upgrade. Connect uses the runtime's global `WebSocket`. Bun, Deno, and Cloudflare Workers have it built in; on Node it needs **Node 22+** (where `WebSocket` became a stable global) or a polyfill. ### Reported metadata The SDK sends this handshake metadata when it connects; Duraton persists it and exposes it on [`GET /runners`](/reference/api/runners). Every field is self-reported by the runner - only what a runner sends is shown, nothing is inferred. | Field | What it is | | --------------------- | --------------------------------------------------------------------- | | `framework` | `connect` for the WebSocket transport. | | `runtime` | The JS runtime: `node`, `bun`, or `deno`. | | `sdkName` + `version` | The SDK package and its version. | | `region` | The deployment region, from `DURATON_REGION` when the runner sets it. | ## Liveness Duraton trusts a runner endpoint only while it keeps checking in: an endpoint Duraton has not seen for **90 seconds** ages out of routing, so an anycast run is never sent to a runner that has gone away - a crashed replica, or an endpoint left over from an earlier deploy. Duraton heartbeats the socket every `30s`, so a healthy runner is refreshed about three times per window. Nothing to configure. The console's **Apps** view shows each runner's last-seen time and a **Live** or **Stale** badge; the same two values are on [`GET /runners`](/reference/api/runners) as `lastSeenAt` and `live`. A connect runner's last-seen advances with every socket heartbeat, so one whose process is gone turns **Stale** instead of sitting on a Live badge indefinitely. A stale runner is listed, not hidden - so you can see that a replica stopped reporting. To take a runner out of routing on purpose, call the connect runner's `close()`: it deregisters immediately. See the [production guide](/core/production#graceful-shutdown-and-draining) for the full drain sequence. An endpoint that is **not** closed cleanly - either side lost abruptly - stays listed until it ages out, up to the full 90 seconds. An invoke routed to one in that window comes back as a retriable transport error, so Duraton re-dispatches the run to a live runner, or [parks it](#when-no-runner-is-registered) until one appears. The run is not failed, and there is nothing to retry by hand. ## Pinning and anycast Declare a stable `runner` id and a run can be **pinned** to that exact instance by setting `runner` on the event. Omit it on the event and the run is **anycast** to any of the app's runners. ```ts theme={null} await duraton.events.send({ name: "ticket.created", app: "support", runner: "agent-node-1", // route this run to that one instance; omit for anycast data: { ticketId: "T-421" }, }); ``` See the [protocol reference](/reference/wire-protocol#routing-app-runner) for how a pin is resolved. ### When no runner is registered A run whose app has no live, capable runner does **not** fail on the spot - and `POST /events` still returns `202`, because dispatch is asynchronous. The run **parks and retries**: the engine re-checks for a runner about once a second and resumes the moment a capable runner (re)registers. The wait is bounded - after **5 minutes** with no runner, measured from a durable stamp so a restart cannot reset it, the run fails terminally with a message naming the workflow and app. This is why the common ordering race self-heals: send an event, then start the runner a moment later, and the queued run simply waits and picks up as soon as the runner is live - you don't have to register before you emit. The behavior is the same for **anycast and pinned** runs. A pin to a runner that is briefly absent - a rolling restart of that instance, say - parks and waits for it to come back rather than failing its in-flight runs, up to the same bound. (This is a change from earlier releases, where a pin to a not-currently-registered runner failed fast.) For deploying runners without stranding in-flight runs, see the [production guide](/core/production). # Steps Source: https://docs.duraton.ai/core/steps Wrap each unit of work in a step and it runs once: the result is saved, it retries on its own, and the run resumes from it after a crash. Steps are how a workflow does durable work. Each step runs once, its result is saved, and it becomes a checkpoint the workflow can resume from. Wrap every unit of real work in a step. Every step takes a unique **id** as its first argument; Duraton keys the saved result by it. | Method | Returns | Purpose | | ----------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------- | | `step.run(id, fn)` | the fn's value | Run a function once and memoize its result. | | `step.skip(id, reason?)` | `void` | Record a deliberately bypassed step as terminal `skipped` (with an optional reason). | | `step.sleep(id, duration)` | `void` | Pause durably for a duration. | | `step.sleepUntil(id, at)` | `void` | Pause durably until an absolute time. | | `step.waitForEvent(id, opts)` | the event payload, or `null` | Pause until a named event arrives or the timeout elapses. | | `step.poll(id, probe, opts)` | the ready value | Re-check an external resource until it is ready, without spending the step's retry budget. | | `step.runWorkflow(id, opts)` | the child's result | Invoke another workflow as a child run and wait for it. | | `step.emit(id, opts)` | `void` | Publish an event from inside a run. | | `step.approval(id, opts)` | the decision | Park the run until a human approves or rejects. See [Approvals](/ai/approvals). | | `step.ai.*` | the call's result | Model calls as durable, metered steps: `generate`, `wrap`, `embed`, `loop`. See [AI steps](/ai/ai-steps). | The console renders a run's steps three ways - a **list**, a **flow** graph, and a **timeline** on a shared time axis, where a long `sleep` or `waitForEvent` shows as a gap and parallel steps overlap. A run's steps as a flow graph ## `step.run` Run a function once and remember its result. ```ts theme={null} const triage = await ctx.step.run("triage", () => triageTicket(ticket)); ``` The first time, the function runs and its return value is saved. On any later pass, `step.run` returns the saved value without running the function again. The return value is whatever your function returns (it must be JSON-serializable, since it's stored). Put anything with a side effect or a changing result inside a `step.run` - API calls, database writes, payments, reading the clock. See [Durable execution](/core/durable-execution) for why. ### Recording a step's input Pass an explicit input to record it on the step, so it shows on the console's **Input** tab. The same value is also handed to the function: ```ts theme={null} const triage = await ctx.step.run("triage", { ticketId, amount: 4200 }, (input) => triageTicket(input.ticketId, input.amount), ); ``` The recorded input shows on the step's **Input** tab in the console. It is optional: the bare `step.run(id, fn)` form captures no input (its arguments live in the function's closure, which Duraton cannot see). The structural steps below record their input automatically - a `runWorkflow`'s child input, an `emit`'s payload, a `waitForEvent`'s event and timeout, a `sleep`'s duration - so the Input tab is backed wherever a step has a meaningful input. ## `step.skip` Record a step you deliberately bypassed. Without it, a conditionally-omitted step simply never appears, so a stage you chose not to run is indistinguishable from one that never existed. `step.skip` records a terminal `skipped` step - with an optional reason - so the bypass is visible in the run's steps, the flow graph, and the timeline. ```ts theme={null} if (triage.priority !== "high") { await ctx.step.skip("page-on-call", "not high priority"); } else { await ctx.step.run("page-on-call", () => pageOnCall(ticket)); } ``` The reason is stored as the step's output. `step.skip` is durable and replay-safe (the `id` must be stable across replays), and is available in the TypeScript SDK. ## `step.sleep` Pause the workflow for a duration. The wait is durable: the process can restart during it and the run still wakes up on time. ```ts theme={null} await ctx.step.sleep("wait-for-settlement", "1h"); ``` The duration is a string like `"10s"`, `"5m"`, `"1h"`, or a number of milliseconds. Sleeps can be short or span days - Duraton owns the schedule, so nothing has to stay running in the meantime. ## `step.sleepUntil` Pause until an absolute instant rather than for a relative duration. Use it when the wake time is a fixed wall-clock target - midnight, a billing date, a scheduled send. ```ts theme={null} await ctx.step.sleepUntil("resume-on-renewal", subscription.renewsAt); ``` | Argument | Type | Description | | -------- | -------------------------- | ---------------------------------------------------------------------------- | | `at` | `Date \| string \| number` | The absolute wake time: a `Date`, an ISO 8601 string, or epoch milliseconds. | Reach for `sleep` when you mean "wait this long" and `sleepUntil` when you mean "wait until this moment." Computing `target - Date.now()` to fake an absolute wait is wrong: it reads the clock outside a step. A target already in the past wakes immediately. ## `step.waitForEvent` Pause until a named event arrives, or until the timeout elapses. Returns the event's data on arrival, or `null` on timeout. ```ts theme={null} const approval = await ctx.step.waitForEvent("await-approval", { event: "ticket.approved", timeout: "24h", }); if (approval === null) return { status: "expired" }; ``` | Option | Type | Description | | --------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `event` | `string` | The event name that resumes this step. | | `timeout` | `string \| number` | How long to wait before resolving to `null`. | | `if` | `string` | Optional [CEL](/core/triggers#filters) predicate on the event payload. The run resumes only on an event whose name matches **and** whose payload satisfies `if`. | An incoming event resumes every run waiting on that name. An event that arrives *shortly before* the run parks still wakes it: on parking, the step also looks back over recently-received events and resumes immediately if a matching one already arrived. This closes the race where a fast responder emits its event before the waiting run reaches its `waitForEvent`, so a request/response pattern never waits out its full timeout just because the reply came back first. The look-back is bounded: only events received within a short window before the park - **60 seconds** - and never older than the run itself are considered, and the same `if` predicate below still applies, so a stale or unrelated event of the same name never wakes the wrong run. The window is fixed platform-wide and is not a per-call, per-workflow, or per-project knob. ### Correlated waits Use `if` to wait for *the* event that belongs to this run, rather than any event of that name. The predicate uses the same CEL dialect as [event trigger filters](/core/triggers#filters) - `event.name` and `event.data` are in scope: ```ts theme={null} const payment = await ctx.step.waitForEvent("await-payment", { event: "payment.settled", timeout: "1h", if: `event.data.ticketId == "${ticketId}"`, }); ``` Without `if`, correlating a wait to a specific entity forces the id into the event name (`payment.settled.`), which explodes event-name cardinality. The filter keeps one stable event name and matches on the payload instead. ## `step.poll` Re-check an external resource until it is ready. A resource that is still provisioning is not a failure - it is a normal intermediate state - so `step.poll` re-checks on a fixed interval **without spending the step's retry budget**, and gives up after an overall deadline. Pass a **probe** that reads the resource and returns its value once ready, or a "not ready yet" signal otherwise. Between checks the run is suspended durably, exactly like `step.sleep` - it holds no worker and survives a restart. On the first ready check `step.poll` resolves with the value. Each check is two durable steps, not a free suspension: a `step.run` probe call plus a `step.sleep` gap. A short `every` against a long `timeout` produces one checkpoint pair per interval - for example `every: "5s"` over a `timeout: "10m"` is up to 120 checks, so 240 durable steps. Prefer the widest `every` the resource's provisioning time tolerates. ```ts theme={null} const record = await ctx.step.poll("provision", () => fetchRecordOrNull(), { every: "5s", timeout: "10m", until: (v) => v != null, }); ``` A probe that returns a value (or one that satisfies `until`) means **ready**. A probe that returns `null`/`undefined` (or one `until` rejects) means **not ready**: the run waits `every` and checks again. | Option | Type | Description | | ----------- | -------------------- | --------------------------------------------------------------------------------- | | `every` | `string \| number` | Delay between checks - a duration string like `"5s"` or a number of milliseconds. | | `timeout` | `string \| number` | Overall deadline for the whole wait. Once it passes, `step.poll` gives up. | | `until` | `(value) => boolean` | Optional readiness predicate. When omitted, a non-null value is treated as ready. | | `maxChecks` | `number` | Optional cap on the number of checks, as a safety bound. | A probe that **throws** is a real error, not a "not ready" signal: it retries under the normal [step retry policy](/core/retries) and, if it exhausts its attempts, fails the run. Readiness (a "not ready" return) and failure (a throw) stay fully separate, so waiting for a resource never consumes the retry budget reserved for genuine errors. ### When the deadline passes If the resource is still not ready when `timeout` elapses, `step.poll` throws `PollTimeoutError` and the run fails, routing to [`onFailure`](/core/retries#onfailure) if one is declared. Giving up on a required resource is a genuine failure, so this is the default. To treat "not ready in time" as a normal branch rather than a failure, catch it: ```ts theme={null} import { PollTimeoutError } from "@duraton/sdk"; try { const record = await ctx.step.poll("provision", () => fetchRecordOrNull(), { every: "5s", timeout: "10m", }); return { status: "ready", record }; } catch (err) { if (err instanceof PollTimeoutError) return { status: "still-provisioning" }; throw err; } ``` ### Poll vs retry Polling and retrying look similar but answer different questions. A [retry](/core/retries) handles a step that **failed** - it re-runs the same work after a backoff and spends an attempt from the step's budget each time; [`RetryAfterError`](/core/retries#retryaftererror) only changes *when* that next attempt runs, and the run still fails once the budget is exhausted. A poll handles a resource that has not **become ready** yet - each check is a successful read, so it never spends the retry budget, and the wait is bounded by a wall-clock deadline instead of an attempt count. Reach for retry when a call can fail transiently; reach for poll when a call succeeds but the answer is "not yet." ## `step.runWorkflow` Invoke another workflow as a **child run** and wait for its result. The parent blocks until the child reaches a terminal state; if the child fails, the failure cascades to the parent. ```ts theme={null} const verdict = await ctx.step.runWorkflow("fraud", { name: "ticket.fraud-check", data: { ticketId }, }); ``` | Option | Type | Description | | -------- | --------- | -------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | The child workflow to invoke. | | `app` | `string` | Optional. Invoke the workflow in this specific app. Omit to resolve the name in the calling app first, then any other app. | | `runner` | `string` | Optional. Pin the child to a specific runner within the target app. | | `data` | `unknown` | Optional input passed to the child as its event data. | When two apps define a workflow with the same name, set `app` to target one exactly: ```ts theme={null} const triage = await ctx.step.runWorkflow("triage", { name: "triage", app: "billing", data: { ticketId }, }); ``` ## `step.emit` Publish an event from inside a run. It can trigger other workflows or resume `waitForEvent` steps. ```ts theme={null} await ctx.step.emit("notify", { name: "notification.requested", data: { ticketId, kind: "triaged" }, }); ``` | Option | Type | Description | | ---------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | `string` | The event name to publish. | | `app` | `string` | Optional. Deliver only to workflows in this app. Omit to deliver to every workflow that triggers on the event. | | `data` | `unknown` | Optional event payload. | | `dedupeId` | `string` | Optional. Drops a repeat of the same event (per app) within the dedupe window - the same idempotency key [`POST /events`](/reference/api/events) accepts. Available in the TypeScript SDK. | ## Step ids The first argument to every step is its **id** (`"triage"`, `"wait-for-settlement"`). The id is how Duraton matches a step to its saved result across passes, so: * Keep ids **stable** across replays - don't compute them from changing values like timestamps, random values, or array contents. An id that changes between passes won't match the work already done, so the step runs again. This also bites when you rename a step in a new deploy while runs are in flight - see [changing step ids across deploys](/core/production#step-ids-across-deploys). * Give distinct work distinct ids. Two *different* steps that happen to share an id would resolve to the same saved result. ### Reusing an id (loops) Reusing the same id is legitimate and expected - a step inside a loop runs once per iteration under one id, and that is not an error. The SDK disambiguates repeats **positionally**, in execution order: the first occurrence of an id keeps it bare and each later occurrence gets a numeric suffix - `fetch-page`, then `fetch-page:1`, `fetch-page:2`, and so on. Each occurrence is its own durable step with its own saved result. ```ts theme={null} for (const page of pages) { // "fetch-page", "fetch-page:1", "fetch-page:2", ... - one durable step per iteration await ctx.step.run("fetch-page", () => fetchPage(page)); } ``` Because the suffix is assigned by execution order, the loop must be **replay-deterministic**: it has to run the same iterations in the same order on every pass, or the suffixes shift and later occurrences stop matching their saved results. Drive the loop from already-durable data - the event payload or a prior step's result - not from a live source that could return a different set on the next pass. ## Ordering Steps run top to bottom, one after another. Each `await` completes before the next step begins, which is what lets a workflow resume at exactly the right place. ```ts theme={null} const triage = await ctx.step.run("triage", () => triageTicket(ticket)); await ctx.step.sleep("cool-off", "1h"); const refund = await ctx.step.run("refund", () => issueRefund(triage)); ``` If this run is interrupted after `triage`, it resumes at the sleep; after the sleep, it resumes at `refund`. Completed steps are never repeated. ## Parallel steps Run independent steps concurrently with `Promise.all`. Duraton discovers the whole batch in one pass and runs the branches together instead of one per round trip. ```ts theme={null} const [user, prefs, plan] = await Promise.all([ ctx.step.run("user", () => fetchUser(id)), ctx.step.run("prefs", () => fetchPrefs(id)), ctx.step.run("plan", () => fetchPlan(id)), ]); ``` Each branch is still its own durable step with its own id and saved result. The workflow continues past the `Promise.all` only after **every** branch has completed - the join. Branches can mix step kinds; a parallel `step.sleep` or `step.waitForEvent` parks alongside the others, and the run wakes as each deadline arrives. If one branch fails after exhausting its [retries](/core/retries), the run fails (the same as `Promise.all` rejecting) and its still-running sibling steps are cancelled - nothing is left dangling. Use `Promise.allSettled` instead when you want every branch to finish regardless. One caveat: batching is best-effort. A branch that does its own `await` (an un-stepped `fetch`, say) *before* calling its `step.run` may be discovered on the next pass rather than with its siblings. It still runs correctly - it costs an extra round trip. Call your steps directly inside the `Promise.all` to keep them in one batch. # Transactional run start Source: https://docs.duraton.ai/core/transactional-start Start a run atomically with an app-side database write - the outbox recipe and the dedupeId-keyed retry pattern. `duraton.events.send()` is a **network call**, so "insert this row and start its run" cannot be a single database transaction. That's the one structural change when you move off an in-process durable engine that shared your database. The failure you're avoiding: * **Commit first, then send** - and if the process dies in between, the row exists but its run never started. * **Send first, then commit** - and if the commit fails, a run is now processing a row that doesn't exist. Neither ordering is safe on its own. The fix is to make the *send* retriable and *idempotent*, so "send until it lands" is safe to repeat. Duraton gives you that with an event `dedupeId`: a repeat of the same id within 24 hours (per project, per app) is dropped **before any fan-out** - no run, no waiters woken, no log row - and the response is `202 { "deduped": true }`. That is exactly the at-least-once safety net a retry loop needs. Pick the id from your own write, not a fresh random each attempt - the row's primary key, or a natural key like `order:A1:created`. Same write, same `dedupeId`, so every retry of that write collapses to one run. ## Pattern 1: the transactional outbox (recommended) Write the domain row **and** an outbox row in one local transaction, then relay the outbox to Duraton out of band. The transaction is fully local, so it's atomic; the relay turns "the row is committed" into "the run is guaranteed to start, at least once". **Step 1 - one local transaction writes both rows.** ```ts theme={null} await db.transaction(async (tx) => { await tx.insert(orders).values(order); await tx.insert(outbox).values({ id: order.id, // used as the dedupeId - stable across retries event: "order.created", app: "shop", payload: { orderId: order.id }, sentAt: null, }); }); ``` **Step 2 - a relay drains unsent outbox rows and sends each with its id as the `dedupeId`.** ```ts theme={null} const pending = await db.select().from(outbox).where(isNull(outbox.sentAt)); for (const row of pending) { await duraton.events.send({ name: row.event, app: row.app, data: row.payload, dedupeId: row.id, // a retry of this row is dropped, not double-run }); await db.update(outbox).set({ sentAt: new Date() }).where(eq(outbox.id, row.id)); } ``` If the relay crashes after `send()` but before it marks the row sent, the next pass re-sends the same `dedupeId` and Duraton drops it - the run starts exactly once. Run the relay on a short poll, or trigger it right after commit and let the poller be the backstop. The dedupe window is **24 hours**. Keep the relay's retry horizon well inside it (a healthy relay drains in seconds). A row that first landed but whose acknowledgement was lost, then re-sent more than 24h later, would start a second run - so a backlog that ages past a day needs reconciliation, not a blind re-send. ## Pattern 2: dedupeId-keyed retry (no outbox table) If you don't want a second table, commit the domain row first - the row is the source of truth - then send the event keyed to the row id, retrying on failure: ```ts theme={null} const order = await db.insert(orders).values(newOrder).returning(); await sendWithRetry(() => duraton.events.send({ name: "order.created", app: "shop", data: { orderId: order.id }, dedupeId: order.id, // safe to retry: a repeat is dropped }), ); ``` The trade-off vs the outbox: if the process dies *after* the commit but *before* the send ever succeeds, nothing retries automatically - you need a sweep that finds rows with no corresponding run (query [`GET /runs`](/reference/api/runs) or your own bookkeeping) and re-sends them, again keyed by row id so the re-send is safe. The outbox is that sweep, made durable. Use Pattern 2 when an occasional reconciliation job is acceptable; use Pattern 1 when start-exactly-once must be automatic. ## Which dedupe to use Two mechanisms both surface `deduped: true`; they solve different problems, and for transactional start you want the **event-level** one: | | Event `dedupeId` | Workflow [`idempotency`](/core/flow-control#idempotency) | | ------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------- | | Keyed on | An id you send on the event | A path into the event payload (e.g. `orderId`) | | Window | 24h, per project + app | Configurable `periodMs` (default 24h) | | Drops | The **whole event**, before any fan-out | One duplicate **run** of that workflow | | Side effects | No run, no waiters woken, no log row | Event is still recorded and still wakes `waitForEvent` waiters | | Use for | Making a retried `POST /events` safe - **the transactional-start case** | At-most-one-run-per-key admission, independent of who sent the event | They compose: `dedupeId` guarantees your retrying sender starts the run once, and a workflow `idempotency` key on the same field is a belt-and-suspenders backstop against a *different* producer emitting the same logical event. See the [flow-control reference](/core/flow-control#idempotency) for the contrast in full. ## Related What an event is and how it fans out to workflows. The POST /events request body, including dedupeId. Workflow idempotency, and how it differs from event dedupe. Deploying and draining a runner fleet. # Triggers Source: https://docs.duraton.ai/core/triggers Start a run from exactly the right thing: event triggers with CEL filters and wildcards, cron schedules, or a manual trigger with no event at all. A workflow declares what starts it with a `triggers` array. A trigger is either an **event** trigger (fires on a matching event) or a **cron** trigger (fires on a schedule). A workflow can mix several. ```ts theme={null} const fulfillment = workflow({ name: "fulfillment", triggers: [ { event: "ticket.created" }, { event: "ticket.reprocessed", if: "event.data.total > 100" }, { cron: "TZ=UTC 0 9 * * *" }, ], handler: async (ctx) => { // ctx.event.name tells you which event (or cron) started this run }, }); ``` If you omit `triggers`, the workflow is started by an event whose name equals the workflow name - the default, so nothing changes for workflows that don't opt in. ## Event triggers An event trigger fires when an incoming event's name matches `event`, optionally gated by an `if` filter. One event fans out to **every** matching workflow, and a workflow with several matching triggers runs once. An event reaches Duraton two ways - from inside another workflow with the SDK, or from outside over the [REST API](/reference/api/events): ```ts theme={null} await ctx.step.emit("reprocess", { name: "ticket.created", app: "support-app", data: { ticketId: "T-421", total: 250 }, }); ``` ```sh theme={null} curl -X POST $DURATON_URL/events \ -H "Authorization: Bearer $DURATON_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421","total":250}}' ``` The console's **Events** view can post the same request from its **Trigger event** dialog. ### Wildcards An event name can end in a single trailing `*` to match a prefix: ```ts theme={null} triggers: [{ event: "ticket.*" }] // ticket.created, ticket.shipped, ticket.refunded, ... ``` The `*` is allowed only as the final character. A pattern with a `*` anywhere else is rejected at registration; there is no mid-string match and no multi-segment `**`. ### Filters `if` is a [CEL](https://cel.dev) expression evaluated against the event. It sees one variable, `event`, with `event.name` and `event.data`: ```ts theme={null} triggers: [{ event: "ticket.created", if: 'event.data.total > 100 && event.data.tier == "pro"' }] ``` The filter is an admission gate: if it isn't `true`, the workflow doesn't start for that event. It runs once at ingest and never again on replay, so it must not depend on anything but the event. ## Cron triggers A cron trigger fires the workflow on a schedule - no event needed. ```ts theme={null} triggers: [{ cron: "TZ=Europe/Paris 0 9 * * *" }] // 09:00 Paris time, every day triggers: [{ cron: "CRON_TZ=UTC 0 9 * * 1-5" }] // 09:00 UTC, weekdays triggers: [{ cron: "@every 30m" }] // every 30 minutes triggers: [{ cron: "TZ=UTC @daily" }] // descriptor macro, midnight UTC ``` | Part | Form | Description | | --------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Schedule | 5-field cron (`0 9 * * *`), `@every `, or a descriptor macro | When the workflow fires. | | Timezone prefix | `TZ=Area/City` or `CRON_TZ=Area/City` | Optional, and worth setting on every cron: an expression with no prefix is resolved in Duraton's own timezone, not yours. `@every` is a relative interval and ignores the zone. | | Event data | `{ cron, scheduledFor }` | The cron spec and the RFC 3339 instant the tick fired for. | | `event.name` | the cron spec | A cron run has no event name of its own, so `ctx.event.name` is the spec string. | ### Descriptor macros Shorthand for the common 5-field expressions below - same admission rules, same skip-and-forward behavior, and the `TZ=`/`CRON_TZ=` prefix still applies. | Descriptor | Equivalent | Fires | | ----------------------- | ----------- | --------------------------------- | | `@yearly` / `@annually` | `0 0 1 1 *` | Once a year, midnight Jan 1 | | `@monthly` | `0 0 1 * *` | Once a month, midnight on the 1st | | `@weekly` | `0 0 * * 0` | Once a week, midnight Sunday | | `@daily` / `@midnight` | `0 0 * * *` | Once a day, midnight | | `@hourly` | `0 * * * *` | Once an hour, on the hour | Missed ticks are **not** backfilled: if a tick cannot fire, the schedule advances to the next one (skip-and-forward), and overlapping schedules fire a given tick only once. Cron runs go through the same retries and flow control as event-triggered runs - pair a frequent cron with `singleton` to stop a slow job overlapping itself. ### Detecting a dead schedule Skip-and-forward is silent by design: a tick that finds nothing to do just advances to the next one, with no run and no error. That means a schedule whose *sweep* has stopped entirely - the engine was down, or a bug broke the sweep loop - looks the same as a healthy schedule that simply had nothing to do. [`GET /workflows`](/reference/api/workflows) exposes two fields per schedule so the two are distinguishable from the outside, with no change to skip-and-forward itself: | Field | Type | Meaning | | ------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lastFiredAt` | RFC3339, optional | When the sweep last claimed a tick for this schedule. Absent if it never has. | | `isStale` | boolean | `true` once the cadence implies at least two ticks have fully passed since `lastFiredAt` (or since the workflow's `registeredAt`, if it has never fired) with none claimed. One missed tick is tolerated as ordinary jitter; two is treated as the sweep itself having stopped. | ```json theme={null} { "cron": "0 9 * * *", "nextFireAt": "2026-08-08T09:00:00Z", "lastFiredAt": "2026-08-07T09:00:00Z", "isStale": false } ``` `isStale` is derived at read time from `lastFiredAt` and the cron expression - Duraton keeps no separate missed-tick counter, so there is nothing else to poll or reconcile. Alert on `isStale: true` the same way you'd alert on a stale heartbeat elsewhere in your stack. ### Fire once on start By default a cron waits for its next scheduled tick. Set `runOnStart` to also fire the workflow once immediately when it is registered (on each runner startup or deploy), for a catch-up run before the regular schedule takes over: ```ts theme={null} triggers: [{ cron: "TZ=UTC 0 * * * *", runOnStart: true }] // hourly, plus once on each deploy ``` The immediate run is claimed exactly once even when a fleet registers concurrently. Because it fires on every registration, pair it with `singleton` or an idempotency key if a redeploy must not repeat work. `runOnStart` is available in the TypeScript SDK. Each cron run carries `triggerKind: "cron"` - see [trigger kinds](#trigger-kinds) for the full set. In the console, a scheduled workflow shows a clock badge and its next run time, plus a stale badge when `isStale` is true. ## Trigger a run manually A cron trigger needs no event to fire - which means, until now, a cron-only workflow had no event to send it either: `POST /events` matches by event **name**, and a cron trigger declares no event pattern to match against. `POST /workflows/{app}/{name}/trigger` closes that gap: it starts one run of one workflow by identity, independent of its declared triggers. It works the same way for every workflow, whether it's event-triggered, cron-triggered, both, or neither - this is the cron-only case that had no workaround before. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); // Fire the cron-only rollup right now, off its schedule. const res = await duraton.workflows.trigger("support-app", "metrics.rollup"); res.runId; // absent if a flow-control gate skipped it - see below // Or override the input a run starts with. await duraton.workflows.trigger("support-app", "metrics.rollup", { input: { scheduledFor: new Date().toISOString() }, }); ``` ```sh theme={null} # Fire the cron-only rollup right now, off its schedule. curl -X POST "$DURATON_URL/workflows/support-app/metrics.rollup/trigger" \ -H "Authorization: Bearer $DURATON_API_KEY" ``` ### It never touches the schedule A manual trigger is a fully independent, one-off run. It never reads or writes a cron trigger's `nextFireAt` cursor, so the next scheduled tick fires at exactly the time it always would have, whether or not you also triggered the workflow manually in between. This is the same guarantee a `kubectl create job --from=cronjob/...` run gives a Kubernetes CronJob, or a manual run of a GitHub Actions workflow that also has an `on: schedule` trigger: firing one now never reschedules the recurring one. ### `eventName` is a label, not an event The optional `eventName` field only sets `ctx.event.name` on the run it starts - it does not fire an event. No workflow's [event trigger](#event-triggers) matches it, nothing fans out, and no [`step.waitForEvent`](/core/steps#step-waitforevent) anywhere wakes up. That's the key difference from the console's **Events** view and its **Trigger event** dialog (backed by [`POST /events`](/reference/api/events)): sending a real event fans out to *every* workflow whose trigger matches that event name and can resume parked waiters. Triggering a workflow manually starts *exactly one* run of *exactly one* workflow - nothing else. Both stay useful for different jobs: reach for `/events` to exercise your event-driven fan-out, and the trigger endpoint to just run one workflow, right now, regardless of what's declared to start it. Set `eventName` when you want the manually-started run to look like it came from a specific event - the same `if`-branch behavior a real event-triggered run would take, if your handler inspects `ctx.event.name`. Leave it out and `ctx.event.name` defaults to the workflow's own name. ### Flow control still applies [Flow control](/core/flow-control) - debounce, batch, rate-limit, singleton, idempotency - gates a manual trigger exactly the way it gates an event- or cron-triggered run. A manual trigger is not a bypass. That means a request can legitimately produce **no run**: a singleton workflow already in flight skips it, a debounced workflow coalesces it into the pending window, and so on. The response reports which gate fired (`skipped` / `dropped` / `debounced` / `batched` / `deduped`) instead of a `runId` - see the [full response shape](/reference/api/workflows#trigger-a-run-manually). Render that as an explicit outcome ("already running, skipped") rather than a failure; a missing `runId` is not an error. ## Trigger kinds Every run carries a `triggerKind`, recording what started it: | Kind | Set on | Notes | | -------- | ------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | | `event` | A run started by a matching [event trigger](#event-triggers). | Carries `eventId`, linking back to the event that fanned it out - see [event->run lineage](/reference/api/runs). | | `cron` | A run started by a [cron trigger](#cron-triggers)'s schedule. | `ctx.event.name` is the cron expression; `ctx.event.data` is `{ cron, scheduledFor }`. | | `manual` | A run started by [triggering a workflow manually](#trigger-a-run-manually) - a person or a script, not a schedule or an event. | `ctx.event.name` defaults to the workflow's name, or your `eventName` override. | Filter the runs list to one kind with `?runType=` - `GET /runs?runType=manual`, `?runType=cron`, and so on. See [listing & filtering](/reference/api/runs#listing-&-filtering). ## Limits | Limit | Value | Description | | ----------------------- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Wildcard position | final character only | `ticket.*` matches; `ticket.*.eu` and `**` are rejected at registration. | | Filter scope | the `event` variable | `if` sees only `event.name` and `event.data`. It cannot read a run, a step, or the clock. | | `waitForEvent` matching | exact name (+ optional CEL `if`) | Wildcards apply to triggers only. [`step.waitForEvent`](/core/steps#step-waitforevent) rendezvouses on an exact event name, with an optional [`if`](/core/steps#correlated-waits) payload filter for correlated waits. | | Causal depth | 64 | An emit-triggered run inherits the emitting run's depth + 1, and a run at depth 64 fails rather than emitting again. | `step.emit` publishes through the same matching path as an external event, so an emitted event can fan out to wildcard-matching workflows - including, by accident, back to the workflow that emitted it. The depth cap stops such a cycle from looping forever, but it still burns 64 runs getting there. Do not build one. See the **scheduling** example running end to end in [Examples](/start/recipes#run-on-a-schedule). # Workflows Source: https://docs.duraton.ai/core/workflows Define a workflow, group it into an app, and trigger it with an event. A workflow is a durable function started by an event or a schedule. You define it with `workflow`, then connect a runner so Duraton can drive it. ```ts theme={null} import { workflow } from "@duraton/sdk"; interface TicketData { ticketId: string; } const ticketCreated = workflow({ name: "ticket.created", retry: { maxAttempts: 3 }, handler: async (ctx) => { return await ctx.step.run("triage", () => triageTicket(ctx.event.data)); }, }); ``` | Property | Type | Default | Description | | ----------- | ------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `name` | `string` | required | Identifies the workflow. With no `triggers`, an event of the same name starts it. | | `handler` | `(ctx) => Promise` | required | The workflow body. It receives the handler context and does durable work through `ctx.step`. See [Steps](/reference/sdk/steps). | | `triggers` | `Trigger[]` | the workflow's own name | What starts the workflow: event triggers (with filters and wildcards) or cron schedules. See [Triggers](/core/triggers). | | `retry` | `{ maxAttempts }` | `{ maxAttempts: 1 }` | How a failing step retries. See [Retries](/core/retries). | | `onFailure` | `(ctx) => Promise` | none | Compensation or notification that runs once the run has failed. See [Retries](/core/retries#onfailure). | The type parameter (``) types `ctx.event.data`, so your event payload is checked. The full handler context - `ctx.event`, `ctx.step`, `ctx.log`, `ctx.runId`, `ctx.attempt`, and the rest - is documented in [SDK: Steps](/reference/sdk/steps). `workflow` also takes flow-control options (`concurrency`, `throttle`, `rateLimit`, `debounce`, `batch`, `priority`, `singleton`, `idempotency`) and AI spend options (`cap`, `tokenThrottle`); see [Defining workflows](/reference/sdk/defining-workflows). ## Apps and runners An **app** is a named set of workflows that run together in one process. A **runner** is a process hosting one app. `connect()` dials Duraton over an outbound WebSocket, registers the app's workflows, and receives invokes on that socket - so the runner needs no inbound URL and no separate registration call. ```ts theme={null} import { connect } from "@duraton/sdk"; const handle = connect({ apiKey: process.env.DURATON_API_KEY, app: "support-app", workflows: [ticketCreated], }); process.on("SIGTERM", () => handle.close()); ``` Re-connecting re-registers the app's workflows, so restarts and deploys are safe. ## Triggering a run Send an event whose `name` matches a workflow. Duraton creates a run and drives it to completion. ```ts theme={null} import { createClient } from "@duraton/sdk"; const duraton = createClient({ url: process.env.DURATON_URL }); await duraton.events.send({ name: "ticket.created", app: "support-app", data: { ticketId: "T-421" }, }); ``` ```sh theme={null} curl -X POST $DURATON_URL/events \ -H "Authorization: Bearer $DURATON_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421"}}' ``` The response carries the run id. Inspect the run with [`GET /runs/{id}`](/reference/api/runs) and its steps with [`GET /runs/{id}/steps`](/reference/api/runs). The console lists every workflow an app has registered; select one to open its detail drawer, with the definition, live stats, charts, and that workflow's runs. Registered workflows in the console ## The event log Duraton keeps a durable **event log**: every event it ingests is recorded together with what it triggered. [Triggers](/core/triggers) are the other half - what starts a workflow. ### What's recorded An event reaches the log two ways: an external `POST /events` (`source: "api"`) or a workflow's `step.emit` (`source: "emit"`). Each record carries the event (`name`, `app`, `data`), when it arrived, and the outcome - the waiters it resumed and the per-workflow fan-out: ```json theme={null} { "id": "9f2b…", "name": "ticket.created", "app": "support", "source": "api", "data": { "ticketId": "T-421", "subject": "Charged twice" }, "receivedAt": "2026-06-15T09:00:00Z", "woke": 0, "triggered": [ { "workflow": "fulfillment", "runId": "01H…" }, { "workflow": "audit", "runId": "01H…" } ] } ``` An event that matched nothing is still recorded with an empty `triggered` - so a fire-and-forget event that hit no workflow is visible, not lost. A **cron** firing is not an event: it starts a run directly, so it shows up in runs, not here. ### Reading it ``` GET /events # newest first; filter with ?app= ?name= ?limit= GET /events/{id} # one event GET /events/stream # live tail (Server-Sent Events) ``` The console's **Events** view lists the log and live-tails the stream. The stream is a best-effort live view - a slow or reconnecting client can miss events; `GET /events` is the complete record. Recording is best-effort on the ingest path: if the log write fails, event delivery still succeeds (the runs are already durable). The log is not auto-pruned, and listings return a bounded page. See the **events** example running end to end in [Examples](/start/recipes#fan-one-event-out-to-many-workflows). # Documentation Source: https://docs.duraton.ai/index Build an AI agent without code, so you can turn it into something people pay for. Make money with Duraton.ai. Duraton is where anyone builds an AI agent without writing code - so you can turn it into something people pay for. Have a problem AI can solve? Build the agent, no coding required. Every other builder shows you a chat log. Duraton shows you the reason to trust what your agent did: | Check | What it means | | ------------------------------ | ------------------------------------------------------------------ | | **Step record** | every action your agent took, in order - not just its final answer | | **Approval before it happens** | the risky ones park on a person's decision before they run | | **Replay** | re-run any completed run and see exactly what happened | [Build your first agent](/start/first-agent) in the console - no code. Sign in, add a model provider key, and publish an agent from the console. `agent()` and `tool()` in code: an agent whose every turn and tool call is a durable step. Durable model calls, approvals, guardrails, spend caps, streaming, and observability. The durable runtime, the TypeScript SDK, the REST API, and the protocol a runner speaks. ## Building with code The agent kit and the durable runtime underneath it are also available directly in code - the same document a no-code agent publishes, authored by hand instead. Find the sentence closest to your own problem. | What you want | Where to go | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | "It must not act without a human on the risky stuff" | [Approvals](/ai/approvals): park the run on a person's decision, holding no worker | | "My agent spends money and I can't sleep" | [Cost controls](/ai/cost-controls): halt before the call that would overspend, or pause over a rolling window | | "My long agent dies halfway and starts over" | [Durable execution](/core/durable-execution): why a run resumes instead of restarting | | "The model is down and my agent just fails" | [Cost controls](/ai/cost-controls): advance to the next model, and cache repeats at zero spend | | "I want to see what it's doing right now" | [Watching a run](/core/realtime) and [Streaming](/ai/streaming): every step and token, live | | "I need to show someone what the agent did" | [AI observability](/ai/observability): token and cost rollups read from the durable record | | "I want to write the agent, not the loop" | [Agent kit](/agent-kit): `agent()`, `tool()`, approval-gated tools, structured output | ### Running anything else durably The model call is optional. If you came here with an ordinary backend job rather than an agent, start here. | What you want | Where to go | | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | "My background job dies halfway and starts over" | [Durable execution](/core/durable-execution): each step is saved when it completes, and the job resumes at the next one | | "I need this to run on a schedule" | [Cron triggers](/core/triggers#cron-triggers): a schedule with no event behind it at all | | "A flaky API keeps killing the whole job" | [Retries](/core/retries): only the failing step retries; the ones before it keep their results | | "It has to wait hours or days for something" | [Steps](/core/steps): the run suspends holding no worker, and survives a restart while it waits | | "The same job must not run twice for one input" | [Idempotency](/core/flow-control#idempotency): collapse repeats into a single run | | "Too many jobs at once, and I'm rate-limited" | [Flow control](/core/flow-control): concurrency, throttle, rate limit, debounce, batch, priority, singleton | | "A job failed and I need to re-run it" | [Control API](/reference/api/control): replay a finished run, or retry from the exact step that failed | ### Getting work in and out | What you want | Where to go | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ | | "The work arrives from somewhere else" | [Integrations](/integrations): events, webhooks, or a schedule | | "I need to receive a webhook from a third party" | [Webhooks](/integrations/webhooks): a signature-verified POST becomes an event that starts a run | | "Something has to happen when it finishes" | [Outbound webhooks](/integrations/webhooks#ctx-webhook-send): signed delivery, retried, every attempt logged | | "My agent should be able to operate Duraton" | [MCP server](/integrations/mcp-server): list and control runs, send events, decide approvals | Or work straight through the [recipes](/start/recipes): one complete, paste-and-run workflow per task. # AI coding tools Source: https://docs.duraton.ai/integrations/ai-coding-tools Have your AI coding agent set up Duraton for you - the docs MCP, the product MCP, and Duraton's agent rules - from a single prompt. If you build with an AI coding agent (Claude Code, Codex, Cursor, Windsurf, VS Code, and others), you can have it wire up Duraton for you instead of doing it by hand. Duraton publishes a machine-readable setup file that your agent reads and acts on directly. ## Give your agent this prompt Paste this into your agent: ```text theme={null} Fetch https://docs.duraton.ai/agent-setup.md and follow it to set up Duraton in this project: add the Duraton MCP servers, install the project rules, and sign me in. ``` Your agent fetches [`/agent-setup.md`](https://docs.duraton.ai/agent-setup.md) and runs the steps itself - you do not run the commands. ## What your agent will do Following the setup file, the agent: 1. **Detects which agent it is** (Claude Code, Codex, Cursor, Windsurf, VS Code, or a generic `mcpServers` config), so it uses the right commands and config paths. 2. **Adds the Duraton docs MCP** (`https://docs.duraton.ai/mcp`) so it can search and fetch the documentation on demand. 3. **Adds the Duraton product MCP** (`https://mcp.duraton.dev`) so it can drive your runs, events, and approvals. It defaults to the bare URL - your whole workspace, where the agent can switch between and create projects when you sign in - and can pin a single project with a [single-project URL](/integrations/mcp-server) instead. 4. **Installs Duraton's agent rules** by fetching [`/agent-rules.md`](https://docs.duraton.ai/agent-rules.md) and saving it to the agent's rules file (for example `CLAUDE.md`, `AGENTS.md`, or `.cursor/rules/`), so it knows Duraton's core concepts. 5. **Authenticates** - OAuth opens in your browser the first time a Duraton product tool is used, and you complete sign-in and the project pick there. The setup file is instructions for an agent, but it is plain and readable - open [`/agent-setup.md`](https://docs.duraton.ai/agent-setup.md) yourself to see exactly what your agent will do before you run it. ## Guardrails The setup file tells your agent to keep secrets out of the flow: * It will **never ask you for, or store, a Duraton secret key**. Secret keys (`dtn_live_...`) are issued by you in the console under **API Keys** and set as `DURATON_API_KEY` only when [wiring a runner](/start/quickstart) - never for MCP setup, which uses OAuth. * It grants the **least privilege** a task needs: read-only access to inspect runs, write access only to trigger events or control runs. ## Point an assistant at the docs The docs are published in a machine-readable form so an AI assistant can pull them in as context: an `llms.txt` index at [`/llms.txt`](https://docs.duraton.ai/llms.txt), a full single-file dump at [`/llms-full.txt`](https://docs.duraton.ai/llms-full.txt), and clean markdown for every page (add `/content.md` to a doc's markdown path, e.g. `/llms.mdx/docs/guides/quickstart/content.md`). ### Point a tool at llms.txt Any tool that understands `llms.txt` - Cursor's **@Docs**, for example - can index the docs directly. Give it this URL: ``` https://docs.duraton.ai/llms.txt ``` The index links to the clean markdown for each page, so the tool fetches documentation, not rendered HTML. ### Connect over MCP The docs run as a hosted MCP server over Streamable HTTP, so an assistant can search and fetch them on demand with nothing to install. It exposes two tools, `list_doc_sources` and `fetch_docs`, and is at: ``` https://docs.duraton.ai/mcp ``` Add it to your client: ```sh theme={null} claude mcp add --transport http duraton-docs https://docs.duraton.ai/mcp ``` Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (one project): ```json title="~/.cursor/mcp.json" theme={null} { "mcpServers": { "duraton-docs": { "url": "https://docs.duraton.ai/mcp" } } } ``` Add to `.vscode/mcp.json`. VS Code nests servers under a `servers` key: ```json title=".vscode/mcp.json" theme={null} { "servers": { "duraton-docs": { "type": "http", "url": "https://docs.duraton.ai/mcp" } } } ``` Add to `~/.codeium/windsurf/mcp_config.json`. Windsurf names the field `serverUrl`: ```json title="~/.codeium/windsurf/mcp_config.json" theme={null} { "mcpServers": { "duraton-docs": { "serverUrl": "https://docs.duraton.ai/mcp" } } } ``` Most other MCP clients read a `mcpServers` map with an `http` server: ```json title=".mcp.json" theme={null} { "mcpServers": { "duraton-docs": { "type": "http", "url": "https://docs.duraton.ai/mcp" } } } ``` **Use a stdio server instead** If your client speaks stdio rather than HTTP, [mcpdoc](https://github.com/langchain-ai/mcpdoc) serves the same two tools from the `llms.txt`. mcpdoc runs via [`uvx`](https://docs.astral.sh/uv/), so you need `uv` installed. The command downloads mcpdoc on first run. ```sh theme={null} claude mcp add duraton-docs -- uvx --from mcpdoc mcpdoc --urls Duraton:https://docs.duraton.ai/llms.txt --transport stdio ``` Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (one project): ```json title="~/.cursor/mcp.json" theme={null} { "mcpServers": { "duraton-docs": { "command": "uvx", "args": ["--from", "mcpdoc", "mcpdoc", "--urls", "Duraton:https://docs.duraton.ai/llms.txt", "--transport", "stdio"] } } } ``` Add to `.vscode/mcp.json`. VS Code nests servers under a `servers` key: ```json title=".vscode/mcp.json" theme={null} { "servers": { "duraton-docs": { "command": "uvx", "args": ["--from", "mcpdoc", "mcpdoc", "--urls", "Duraton:https://docs.duraton.ai/llms.txt", "--transport", "stdio"] } } } ``` Add to `~/.codex/config.toml`: ```toml title="~/.codex/config.toml" theme={null} [mcp_servers.duraton-docs] command = "uvx" args = ["--from", "mcpdoc", "mcpdoc", "--urls", "Duraton:https://docs.duraton.ai/llms.txt", "--transport", "stdio"] ``` Add to `~/.codeium/windsurf/mcp_config.json`: ```json title="~/.codeium/windsurf/mcp_config.json" theme={null} { "mcpServers": { "duraton-docs": { "command": "uvx", "args": ["--from", "mcpdoc", "mcpdoc", "--urls", "Duraton:https://docs.duraton.ai/llms.txt", "--transport", "stdio"] } } } ``` Add to `claude_desktop_config.json`: ```json title="claude_desktop_config.json" theme={null} { "mcpServers": { "duraton-docs": { "command": "uvx", "args": ["--from", "mcpdoc", "mcpdoc", "--urls", "Duraton:https://docs.duraton.ai/llms.txt", "--transport", "stdio"] } } } ``` Restart the client, and the assistant can list the docs source and fetch any page on demand. See the **AI** examples running end to end in [Examples](/start/recipes#make-a-model-call-durable). ## Related * [MCP server](/integrations/mcp-server) - the full reference for connecting and the tool list. # Credentials Source: https://docs.duraton.ai/integrations/credentials Resolve a stored third-party credential inside a step, in place of an environment variable - the credential is used only transiently and never enters a step's durable input or output. A **credential** is a secret Duraton stores on your behalf (an API key, an OAuth token, SMTP settings) so a workflow can call a third-party service without the credential living in an environment variable. `client.credentials.resolve()` reads one back at execution time. ## Resolve inside a step Resolution must happen **inside** a `step.run` (or `step.ai.*`) closure, never in the workflow handler's top-level body. A step's input and output are durable - they are recorded and replayed forever - so a credential resolved outside a step, then passed into one, would sit in that replayed record permanently. Resolved inside the closure, the credential is used only for the one call that needs it and is gone once the step returns: ```ts theme={null} import { createClient } from "@duraton/sdk"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); await ctx.step.run("publish", async () => { const cred = await duraton.credentials.resolve(credentialId); return post(cred.credential, topic); }); ``` `resolve()` called outside a step - directly in the handler body, or stashed in a variable before a `step.run` call - throws immediately (`CredentialResolveOutsideStepError`) rather than silently handing back a credential with no durable-input protection. | Field | Type | Description | | ------------ | --------------------- | ------------------------------------------------------------------------------------------------------------------------ | | `credential` | `string` | The credential's plaintext, decoded from the wire. Duraton treats it as opaque - see the shapes below for what it holds. | | `authShape` | `CredentialAuthShape` | Which shape `credential` holds. | ## Auth shapes `authShape` tells you how to read `credential` and how to present it to the third party. A single-value shape is the value itself; a multi-field shape is a JSON string you parse yourself. | `authShape` | `credential` holds | | ------------------------------------ | ------------------------------------------------------------------------------------- | | `api_key` | The key itself, with no placement implied - you decide where it goes. | | `header_auth` | `{"name": "X-Api-Key", "value": "..."}` - send `value` as the request header `name`. | | `query_auth` | `{"name": "token", "value": "..."}` - send `value` as the URL query parameter `name`. | | `basic_auth` | The credential itself. | | `oauth2` | `{"access_token": "...", "refresh_token": "..."}`, written and refreshed by Duraton. | | `oauth1`, `smtp`, `incoming_webhook` | Whatever you stored. Duraton defines no schema for these - the bytes are yours. | `header_auth` and `query_auth` are distinct shapes rather than an `api_key` with a placement setting, because placement changes how you use the credential, not just what its bytes are - so it travels in the shape rather than hidden inside the blob. A replayed step never re-invokes its body, so a memoized `resolve()` call never runs twice and never fails if the credential was since deleted - it only had to exist at the moment the step first ran. ## Retrying a rejected credential Duraton refreshes an OAuth credential's access token ahead of its known expiry, so a normal resolve already returns a live token. A token can still be rejected by the provider despite looking valid (clock skew, early revocation) - catch that in the step and let the step's own retry call `resolve()` again: ```ts theme={null} await ctx.step.run( "publish", async () => { const cred = await duraton.credentials.resolve(credentialId); const res = await post(cred.credential, topic); if (res.status === 401) throw new Error("credential rejected, retrying"); return res; }, { retry: { maxAttempts: 2 } }, ); ``` The second attempt calls `resolve()` again from scratch, picking up a refreshed token if one is due. A token rejected well before its known expiry (the provider revoked access early) is not fixed by a retry - the credential needs re-authorization; see [Retries & failure handling](/core/retries) for `NonRetriableError` to fail fast on that case instead of spending the retry budget. # Integrations Source: https://docs.duraton.ai/integrations/index However the work arrives, however the result leaves: events, signed webhooks, credentials to third-party APIs, and an MCP server so an agent can drive Duraton itself. This page is for the code path - wiring events, webhooks, credentials and MCP by hand. To build an agent without code, start with [Build your first agent](/start/first-agent). An agent is only useful if something real can start it and something real happens when it finishes. You do not have to change how your business emits work to get either: a run starts from whatever you already have, and the result leaves the same way. Both directions keep a durable attempt log you can inspect, redeliver, or replay, so "did the partner ever get it?" is a question with an answer. ## Getting a run started Pick whichever you already have; a workflow can be started by more than one. | The work arrives as | How it starts a run | Read | | -------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------- | | An event you send yourself | `POST /events`, or `duraton.events.send()` from any service | [Events API](/reference/api/events) | | A POST from a third party | A webhook source verifies the signature, then turns it into an event | [Webhooks](/integrations/webhooks) | | Nothing at all - it is just time | A cron trigger, with no event behind it | [Triggers](/core/triggers#cron-triggers) | | A person deciding to run it | A manual trigger from the console or the API | [Triggers](/core/triggers#trigger-a-run-manually) | ## Getting the result out | You want to | Use | Read | | ---------------------------------------------- | ---------------------------------------------------------- | --------------------------------------------------- | | Hand off to another workflow | `ctx.step.emit` | [Steps](/core/steps#step-emit) | | Tell an outside system | `ctx.webhook.send` - signed, retried, every attempt logged | [Webhooks](/integrations/webhooks#ctx-webhook-send) | | Call a third-party API with stored credentials | `duraton.credentials.resolve()` inside a step | [Credentials](/integrations/credentials) | ## An agent can drive all of it Duraton ships an [MCP server](/integrations/mcp-server), so an AI agent is a first-class operator: it can list and control runs, send events, and decide approvals. An agent is not only the thing being run; it can be the thing doing the running. The same server, and `llms.txt`, are how you point a coding assistant at [these docs and your project](/integrations/ai-coding-tools). ## What each inbound transport guarantees Every transport ships a capability descriptor you can read from the engine, generated from the same closed sets the API serves. A webhook source is the only transport today, so nothing differs - the descriptor exists so that when a second one lands, its differences are stated rather than implied away. ```bash theme={null} curl -H "Authorization: Bearer $DURATON_API_KEY" "$DURATON_URL/ingress-kinds" ``` ```ts theme={null} const kinds = await duraton.events.ingressKinds(); ``` | | Webhook source | | -------------------------- | ----------------------------------------------------------------------------------- | | **Ordering** (transport) | none | | **Acknowledgement** | synchronous - the response status is the ack | | **Who redelivers** | the sender, on their own policy | | **Run ordering** (Duraton) | none | | **Default dedupe id** | none - every accepted request becomes an event unless the source names a dedupe key | | **Dedupe id configurable** | yes | ### Ordering is two questions, not one **A transport's ordering is not an ordering your runs keep.** A broker that guarantees delivery order within a partition or a queue says nothing about execution order: Duraton does not preserve ordering past ingest, because runs are admitted from a queue sorted on when they are due, with no creation-order tiebreak, and a concurrency key buys mutual exclusion rather than sequence. That is why the descriptor carries **two** fields. `ordering` is what the transport promises on the way in; `runOrdering` is what survives, and it is `none` for every transport today. Reading the first without the second is the mistake this section exists to prevent: a true fact about your broker reads as a promise about your workflows. If your workload needs records for one key handled in order, ordering at the transport is not enough. Model it explicitly: one workflow that processes a batch in order, or a state machine keyed on the entity, rather than assuming per-partition delivery becomes per-partition execution. ### Acknowledgement is what makes durability possible The promise is that work triggered by a message actually finishes. That rests on being able to tell the transport a message was handled, and on *not* telling it when the message was not. * **Synchronous** (webhooks): the HTTP status is the acknowledgement. There is nothing to acknowledge later, so a sender that gives up is the end of it. That is why an inbound delivery is recorded and can be [replayed](/integrations/webhooks#the-inbound-delivery-log). **A transport that acknowledges nothing cannot carry the promise honestly.** Redis pub/sub and MQTT QoS 0 are fire-and-forget: nothing is retained, nothing is replayed, and a consumer that was not listening simply missed the message. Duraton does not offer them as sources rather than accepting them and quietly delivering a weaker guarantee under the same name. ### Where a dedupe id comes from A dedupe id is what makes a redelivery a no-op rather than a second run. There is no universal formula for it, and the descriptor says so per transport: * A webhook has no identifier that is stable across redelivery, so there is no default: a source that wants dedupe names a key to read from the payload or a header. Both are overridable with a template. The default is the transport's to declare, because it depends on what that transport actually keeps stable. A RabbitMQ delivery tag, for instance, is scoped to a channel and is not stable across redelivery, so it could never serve as one. ## Related * [Webhooks](/integrations/webhooks) - inbound sources, outbound subscriptions, signing, and the attempt log. * [Credentials](/integrations/credentials) - stored credentials a step resolves at run time. * [MCP server](/integrations/mcp-server) - what an agent can do to Duraton, and how to connect one. # MCP server Source: https://docs.duraton.ai/integrations/mcp-server Let AI agents drive Duraton - operate runs, events, approvals, and your projects - through the MCP server. Duraton runs a [Model Context Protocol](https://modelcontextprotocol.io) server, so an AI assistant (Claude, Cursor, Codex, and others) can drive Duraton with tools - the same operations as the [REST API](/reference/api), over one endpoint. An agent is a first-class user here, not a read-only observer: what the console lets you do to your runs, events, approvals, and webhooks, the agent can do too. ## Ways to connect There are two ways to connect, and the difference is only **which projects the agent can reach**. Both authenticate the same way - OAuth: the first time your client connects it opens a browser to Duraton, you sign in, and access is bound to your account and the scopes you grant. ### Workspace - the whole account ``` https://mcp.duraton.ai ``` The agent can reach **every project you can access**, list and switch between them, and create new ones. This is the default and the right choice for interactive work: developing against a project, moving across a `dev` / `staging` / `prod` set of projects, or letting an agent set a workspace up from scratch. ### Single project - pinned by URL ``` https://mcp.duraton.ai/ ``` The same sign-in, but the project in the URL is fixed: the agent can only ever touch that one project. Reach for this for an unattended or narrowly-scoped agent - a runner, a CI job, or an assistant you want locked to one environment - so it can never wander into another project. Copy the URL for a project from its **API keys** page in the console. Whichever you use, access is enforced per request against your live workspace membership and role - losing access to a project immediately closes it off, even mid-session. ## Add it to your client ```sh theme={null} claude mcp add --transport http duraton https://mcp.duraton.ai ``` Then run `/mcp` in a session and follow the OAuth flow. Append `/` to the URL to pin the connection to a single project. Add it to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (one project): ```json title="~/.cursor/mcp.json" theme={null} { "mcpServers": { "duraton": { "url": "https://mcp.duraton.ai" } } } ``` Add it to `.vscode/mcp.json`. VS Code nests servers under a `servers` key: ```json title=".vscode/mcp.json" theme={null} { "servers": { "duraton": { "type": "http", "url": "https://mcp.duraton.ai" } } } ``` Add it to `~/.codeium/windsurf/mcp_config.json`. Windsurf names the field `serverUrl`: ```json title="~/.codeium/windsurf/mcp_config.json" theme={null} { "mcpServers": { "duraton": { "serverUrl": "https://mcp.duraton.ai" } } } ``` Most other MCP clients read a `mcpServers` map with an `http` server: ```json title=".mcp.json" theme={null} { "mcpServers": { "duraton": { "type": "http", "url": "https://mcp.duraton.ai" } } } ``` **Claude Desktop** adds a remote server through **Settings -> Connectors -> Add custom connector**, pointed at the URL above. **Codex** authenticates a remote server with a bearer token rather than the interactive OAuth flow, so it cannot sign in to this server from config alone. ## Working with projects Every other tool is scoped to one **active** project, so a run or event id from another project reads back as not-found. In workspace mode the agent manages that itself: | Tool | What it does | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `list_projects` | List the projects you can access. | | `select_project` | Set the active project for later tool calls, by its id (`projectId`). | | `create_project` | Create a new project - an isolated environment such as `dev`, `staging`, or `prod` - then activate it with `select_project`. Needs write access and a workspace owner/admin role. | In single-project mode the active project is fixed by the URL, so `select_project` has no effect there. ## What an agent can do The full, current set of tools is whatever the server advertises to your client on connect (`tools/list`) - that is the source of truth, and it grows as Duraton does. At a high level the tools cover: * **Runs** - list and inspect runs and their steps; cancel, pause, resume, replay, or retry from a step; bulk-cancel or bulk-replay by filter; and get a model-written diagnosis of a failure. * **Events** - browse the event log and emit events to trigger workflows. * **Workflows, apps, and runners** - inspect what is registered, the live flow-control state, and [trigger a run manually](/core/triggers#trigger-a-run-manually) - works even for a cron-only workflow. * **Approvals** - list human-in-the-loop approvals and approve or deny them. Reading is always available; deciding can be restricted above a [risk floor](/ai/approvals#who-may-decide-what) that only a person can clear. * **Webhooks** - manage inbound and outbound webhook endpoints and sources, inspect outbound delivery health, and browse the inbound source delivery log and replay a verified delivery. * **Metrics** - run counts, timeseries, AI spend, and session rollups. **The exact tool set is deployment- and access-dependent.** Write tools appear only when you connect with write access; some tools (the webhook tools) are registered only when the engine is wired for them. Your client always shows what is actually available rather than offering a capability that would only answer "unavailable". **Signing secrets are never returned over MCP.** In the console, creating or rotating a webhook secret shows it to you exactly once. A tool result would put that secret in an assistant's transcript and logs, so the MCP tools never emit one: the webhook create tools take the secret as an argument (supply your own, and configure the same value on the sender or receiver), and rotating a secret stays a console and REST action. # Webhooks Source: https://docs.duraton.ai/integrations/webhooks Let a third party start a run, and let a finished run tell the outside world - verified inbound POSTs, signed outbound ones, one durable delivery log. Webhooks are two halves of one feature. An inbound **source** turns a signature-verified external POST into a Duraton event. An outbound **endpoint** sends a signed POST when a run reaches a lifecycle transition; `ctx.webhook.send` sends one from workflow code. Both directions sign with the same HMAC-SHA256 scheme, and every outbound send lands in a durable delivery + attempt log. Sources and endpoints are config-as-data: create them in the console's **Webhooks** view or over the [webhooks API](/reference/api/webhooks) - both write the same rows. ## Inbound sources A source maps a **receive URL** onto an event. You choose the event; Duraton issues the URL and the signing secret, and returns both once: ```ts theme={null} const source = await duraton.webhooks.sources.create({ name: "Stripe", app: "shop", eventName: "payment.received", }); console.log(source.receiveUrl); // https://webhooks.duraton.dev/9f3c8a2b... - give this to your provider console.log(source.secret); // shown once - store it now ``` The receive URL is not an input: Duraton generates a 128-bit random token for it ([W3C capability-URL guidance](https://www.w3.org/2001/tag/doc/capability-urls/) asks for 120+ bits), so it is unguessable and immutable after create. The URL is **public** - an external caller carries no Duraton API key - so the source is the authority: Duraton looks it up by that token, verifies the signature with the source's own secret, and takes the project and event mapping from it. ```sh theme={null} # what your provider does, signed with the source's secret curl -X POST "https://webhooks.duraton.dev/9f3c8a2b..." \ -H 'content-type: application/json' \ -H 'x-duraton-signature: t=1752400000&s=' \ -d '{ "amount": 4200, "currency": "usd" }' ``` A verified JSON body becomes the event payload and is ingested as `payment.received`, triggering whatever workflows subscribe to it. | Response | When | | -------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `202` | Verified. The event is ingested. | | `400` | The body is not JSON, or could not be read. | | `401` | The signature does not verify. The body carries a `reason` naming which check failed (see [Troubleshooting a `401`](#troubleshooting-a-401)). | | `404` | No source matches the token. | | `413` | The body is over the size limit. | Only `202` starts a run. ### Supported signature schemes A source carries a **signature scheme** and a **signing secret**, and verifies every inbound POST against them. Duraton looks the source up by its receive-URL token, then checks the request with that source's scheme. The default is Duraton's own HMAC scheme; a provider preset lets a source accept a POST signed the way that provider already signs it, so you can point the provider straight at the receive URL with no translation layer. | Scheme | Signature header | How the body is signed | | ----------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `hmac_sha256` (default) | `X-Duraton-Signature: t=&s=` | HMAC-SHA256 over `` `${t}.${rawBody}` ``, hex-encoded, with the source's secret. | | `stripe` | `Stripe-Signature: t=,v1=` | HMAC-SHA256 over `` `${t}.${rawBody}` ``, hex-encoded, with the source's secret; several `v1=` values are accepted so a rolled secret keeps verifying. | | `github` | `X-Hub-Signature-256: sha256=` | HMAC-SHA256 over the raw body, hex-encoded, with the source's secret. No timestamp in the scheme, so there is no skew check. | | `standard_webhooks` | `webhook-id`, `webhook-timestamp`, `webhook-signature: v1,` | HMAC-SHA256 over `` `${id}.${timestamp}.${rawBody}` ``, base64-encoded, with the source's secret (conventionally a `whsec_`-prefixed base64 key); several space-separated signatures are accepted so a rolled secret keeps verifying. | Every timestamped scheme (`hmac_sha256`, `stripe`, `standard_webhooks`) rejects a timestamp more than **5 minutes** from Duraton's clock; `github` carries no timestamp, so it has no skew check. All schemes compare the signature in constant time. New sources use `hmac_sha256` unless you set `scheme`. For a provider scheme, supply the provider's own signing secret as `secret` instead of using the generated one: ```ts theme={null} const source = await duraton.webhooks.sources.create({ name: "Stripe", app: "shop", eventName: "payment.received", scheme: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET, // Stripe's signing secret, not a generated one }); ``` ### Troubleshooting a `401` An inbound POST whose signature does not verify is rejected with `401`, and the JSON body names which check failed in a `reason` field so you can go straight to the cause: ```json theme={null} { "error": "signature verification failed", "reason": "timestamp_out_of_tolerance" } ``` | `reason` | What failed | Where to look | | ---------------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `missing_signature` | The request carried no signature header. | Send the scheme's header (`X-Duraton-Signature`, or `Stripe-Signature` for the `stripe` scheme). | | `malformed_signature` | The signature header was present but could not be parsed. | Match the header format exactly - `t=&s=` for the default scheme, `t=,v1=` for `stripe`. | | `timestamp_out_of_tolerance` | The signed timestamp is more than 5 minutes from Duraton's clock. | Sign with a current timestamp and keep the sender's clock in sync. | | `signature_mismatch` | The header parsed, but the signature did not match the body. | Wrong secret, or the body was altered in transit - work through the checks below. | The signature is over the **exact bytes on the wire**, so a signature that looks right can still fail. For a `signature_mismatch`, check, in order: * **Sign the raw body, byte for byte.** HMAC the exact bytes you transmit - never a re-serialized, re-formatted, or pretty-printed copy, and watch for a **trailing newline** a shell or client adds (`--data-binary` over `--data`, no `echo`). One extra byte changes the hash. This is the most common cause of "I signed it exactly per the docs but still get a 401". * **Timestamp within 5 minutes of Duraton's clock.** The `t` you sign must match the `t` in the header, and both must be current - a stale or skewed clock is rejected the same way a bad signature is. * **Exact secret.** For a provider scheme, HMAC with the provider's own signing secret (Stripe's `whsec_...`), not a generated one; for the default scheme, the source's secret. * **Lowercase hex.** The signature is hex-encoded (64 characters for SHA-256). To rule out the signature itself, sign the same body with the default `hmac_sha256` scheme against a source **that has a secret set** and confirm it verifies - a source with no secret accepts anything, so it is not a valid control. ### Deduplicating deliveries A source takes an optional `dedupeKey`: a dotted path into the inbound payload used to drop duplicate deliveries. When set, Duraton reads the value at that path on each verified delivery; a repeat whose value has already been seen within the dedupe window is accepted (still `202`) but produces **no** event. Providers that retry the same delivery - Stripe resends an event until you `2xx` it - dedupe on the provider's own event id: ```ts theme={null} const source = await duraton.webhooks.sources.create({ name: "Stripe", app: "shop", eventName: "payment.received", scheme: "stripe", secret: process.env.STRIPE_WEBHOOK_SECRET, dedupeKey: "id", // Stripe's event id: `evt_...` }); ``` If the field at `dedupeKey` is absent from a delivery, that delivery is **not** deduplicated - it still produces an event. A wrong path never silently drops every delivery: it fails open, one event per delivery, as if no `dedupeKey` were set. ### The inbound delivery log Every POST to a source's receive URL is recorded in an **inbound delivery log**, alongside its admission outcome - whether or not it became an event. This is the received-side counterpart to the [outbound delivery log](#delivery-retries-and-the-attempt-log): outbound rows are the POSTs Duraton *sends*; inbound rows are the POSTs a source *receives*. A previously verified delivery can be **replayed** to re-ingest its stored body - inbound deliveries are *replayed* (re-injected into the pipeline), while outbound endpoint deliveries are *redelivered* (sent to the endpoint again). Each delivery carries a `status` - the admission outcome of that POST: | `status` | Meaning | | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ingested` | Verified and emitted an event. See the delivery's `eventId`, and its `runId` if a run started. | | `deduped` | Verified, but dropped by the source's [`dedupeKey`](#deduplicating-deliveries), so no event was produced. | | `unauthorized` | The signature did not verify. A `failureReason` names the failed check - the same values as the [`401` reasons](#troubleshooting-a-401): `missing_signature`, `malformed_signature`, `timestamp_out_of_tolerance`, `signature_mismatch`. | | `invalid` | Verified, but the body was not JSON. | | `too_large` | The body was over the size limit. | | `misconfigured` | The source's stored secret could not be read, so the POST could not be verified. | ### Attempts and replay Like an outbound delivery, an inbound delivery keeps an **append-only attempt log**. Attempt 1 is the original ingest (`trigger: initial`); each manual **replay** appends another attempt (`trigger: replay`) that records the actor who triggered it - so a delivery's full history is visible, not only its latest outcome. The delivery's top-level `status` is the **first** admission outcome and stays frozen: a replay never rewrites it, and each replay's outcome lives on its own attempt row instead. The delivery row and each attempt also carry `eventId` (the emitted event) and `runId` (a run it woke), letting you pivot from a delivery to its event to a run; both are omitted when nothing was produced - a deduped or rejected post, or an ingest that matched no workflow. Replay re-ingests the **stored body** through the source's *current* dedupe key and event mapping: * **Only a delivery that originally verified** (`ingested` or `deduped`) is replayable. A rejected delivery (`unauthorized`, `invalid`, `too_large`, `misconfigured`) stored no verified body, so there is nothing legitimate to re-ingest. * **Replay does not re-check the signature.** The delivery was already verified when it arrived, and its signed timestamp would now be far outside the tolerance window. Replay re-runs the *pipeline* - the source's current `dedupeKey` and event mapping - over the stored body, not the signature check. * **Dedupe still applies.** Within a dedupe window a replay dedupes exactly as a real provider redelivery would, so replaying a source that has a `dedupeKey` is idempotent. Without a `dedupeKey`, each replay starts a fresh run. **A replay re-triggers downstream workflow effects.** Re-ingesting a delivery emits its event again, so every workflow subscribed to that event runs again - unless the source's `dedupeKey` dedupes the replay. Replay a delivery whose side effects you are willing to repeat, or rely on a `dedupeKey` to make it a no-op. Inspect and replay a delivery over the SDK - see the [webhooks API](/reference/api/webhooks#the-inbound-delivery-log) for the REST shape: ```ts theme={null} const { deliveries } = await duraton.webhooks.sourceDeliveries.list({ status: "unauthorized" }); const detail = await duraton.webhooks.sourceDeliveries.get(deliveries[0].id); for (const a of detail.attempts) console.log(a.trigger, a.status, a.actor); // re-ingest a previously verified delivery's stored body; the result reports what it produced const result = await duraton.webhooks.sourceDeliveries.replay(detail.id); console.log(result.status, result.eventId, result.runId); // "ingested" | "deduped"; eventId/runId set only when it ingested ``` The inbound delivery log - the receipt, the stored body, and its attempts - is bounded by the same retention sweep that purges runs, events, and outbound deliveries: it is kept only while retention is configured, so received payloads are not held indefinitely. ## Outbound subscriptions An endpoint subscribes a URL to one or more run lifecycle kinds. When a matching transition happens, Duraton enqueues one delivery per subscribed endpoint. ```ts theme={null} const endpoint = await duraton.webhooks.endpoints.create({ name: "Acme prod", app: "shop", // omit to subscribe to every app in the project url: "https://hooks.example.com/duraton", eventKinds: ["run.failed", "run.succeeded"], }); console.log(endpoint.secret); // shown once - this is what signs the deliveries ``` The lifecycle kinds are `run.succeeded`, `run.failed`, `run.cancelled`, and `step.failed`. The delivery payload is a bounded run summary - `runId`, `workflowName`, `app`, `status`, and the error or result - not the full step set. ## `ctx.webhook.send` A workflow can POST to a URL directly. The send is a **durable step**: it is recorded like any other, so a replayed pass never re-sends it. The first argument is its step id, which is what makes the replay deterministic. ```ts theme={null} import { workflow } from "@duraton/sdk"; export const orderShip = workflow({ name: "order.ship", handler: async (ctx) => { await ctx.webhook.send("notify-partner", { url: "https://partner.example.com/shipments", data: { orderId: ctx.event.data.orderId, shipped: true }, }); }, }); ``` A `ctx.webhook.send` carries no endpoint, so it has no secret to sign with and the delivery goes out **unsigned**. Subscribe a registered endpoint when the receiver must verify a signature. ## Signing and verification Every delivery POST carries this header set: | Header | Value | | --------------------- | -------------------------------------------------- | | `Content-Type` | `application/json` | | `User-Agent` | `Duraton-Webhooks/` | | `X-Duraton-Id` | The delivery id. | | `X-Duraton-Timestamp` | The unix send time, matching the signature's `t`. | | `X-Duraton-Event` | The lifecycle kind, e.g. `run.failed`. | | `X-Duraton-Signature` | `t=&s=` - endpoint deliveries only. | The HMAC-SHA256 is computed over `` `${t}.${rawBody}` `` with the endpoint's secret and hex-encoded. Verify it over the **raw** body, before any JSON parse, and compare in constant time: ```ts theme={null} import { createHmac, timingSafeEqual } from "node:crypto"; const MAX_SKEW_SECONDS = 300; export function verify(rawBody: string, header: string, secret: string): boolean { const params = new URLSearchParams(header); // t=&s= const t = Number(params.get("t")); const sig = params.get("s") ?? ""; if (Math.abs(Math.floor(Date.now() / 1000) - t) > MAX_SKEW_SECONDS) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); return sig.length === expected.length && timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); } ``` Duraton rejects an inbound signature more than **5 minutes** from its own clock, and this is the same scheme an inbound source verifies - so a Duraton endpoint can deliver into a Duraton source end to end. ## Delivery, retries, and the attempt log Both triggers write the same delivery row and ride one delivery loop: Duraton signs (for endpoint deliveries), POSTs, records the attempt, and classifies the response. | Response | Outcome | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | `2xx` | `succeeded`. | | `5xx`, `429`, or a transport error (timeout, connection refused) | Retried: `failed` between attempts, `exhausted` once the row's `maxAttempts` is spent. | | Any other `4xx` | `dead` immediately - a non-retryable client error, e.g. a bad URL. | Outbound retries back off **exponentially**: 1s before the second attempt, doubling each time, capped at 30s. This is a different subsystem from [step retries](/core/retries), which wait a **fixed** delay between attempts - a workflow's `retry` policy has no effect on webhook delivery, and a delivery's `maxAttempts` has none on a step. Every POST appends a row to the attempt log - status code, response snippet, error, duration, and the exact request and response headers - so a failing delivery is debuggable from its full history, not only its final state: ```ts theme={null} const { deliveries } = await duraton.webhooks.deliveries.list({ status: "exhausted" }); const detail = await duraton.webhooks.deliveries.get(deliveries[0].id); for (const a of detail.attempts) console.log(a.statusCode, a.durationMs, a.error); await duraton.webhooks.deliveries.redeliver(detail.id); // re-queue it for another attempt ``` ## Egress safety Outbound delivery is a server-side request to a URL you supply, so Duraton guards against SSRF: loopback, private (RFC-1918), link-local (including the `169.254.169.254` cloud-metadata address), and unspecified addresses are **blocked**, checked at dial time against the resolved IP so a DNS rebind cannot slip past. Point endpoints at publicly reachable URLs. # Approvals API Source: https://docs.duraton.ai/reference/api/approvals Decide the runs waiting on a person from your own tooling: list open approvals and approve, deny, or approve with edits over HTTP. An [approval](/ai/approvals) is a step that parks its run in `needs_attention` until someone approves or denies it; the decision resumes the run from its checkpoint. Reads work with a public key; the decision needs a secret key. ## Endpoints | Method + path | Purpose | | ------------------------------- | ---------------------------------------------------------- | | `GET /approvals` | The project's approvals, newest first. | | `GET /approvals/{id}` | One approval, with the proposed tool call and its context. | | `POST /approvals/{id}/decision` | Approve or deny an open approval; the parked run resumes. | ## Listing `GET /approvals` accepts: | Param | Meaning | Default | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | `status` | One of `pending`, `escalated`, `approved`, `denied`, `cancelled`. `pending` and `escalated` are open, awaiting a decision. An unknown value returns `400`. | all | | `runId` | Only one run's approvals. | all | | `limit` | Page size, `1`-`1000`. A non-integer returns `400`. | `100` | A decided approval stays listable as its audit trail. Each approval is: ```json theme={null} { "id": "01JZR4A0...", "runId": "01JZR3Z9...", "workflow": "support.refund", "app": "support", "step": "refund-gate", "tool": "issue-refund", "args": { "orderId": "A1", "amount": 4200, "currency": "usd" }, "risk": "high", "policy": "tools.issue-refund -> require approval", "summary": "Refund 4200 to A1 for a duplicate charge", "escalatesTo": "#support-leads", "onTimeout": "escalate", "status": "pending", "requestedAt": "2026-07-01T10:00:00Z", "expiresAt": "2026-07-01T10:30:00Z" } ``` | Field | Meaning | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id`, `runId` | The approval and the run parked on it. | | `workflow`, `app` | Joined from the parked run. | | `step` | The `step.approval` name in the workflow. | | `tool`, `args` | The proposed action awaiting sign-off. | | `risk` | The declared risk level. | | `policy`, `summary`, `context` | Annotations for whoever reviews it. Absent when not set. | | `escalatesTo` | The escalation target once `expiresAt` passes, on the default `onTimeout: "escalate"`. | | `onTimeout` | What the deadline does to this approval undecided: `escalate` (the default), `approve`, `reject`, or `fail`. See [Timeouts](/ai/approvals#timeouts-and-escalation). | | `status` | `pending`, `escalated`, `approved`, `denied`, or `cancelled`. `cancelled` is terminal with no decision on record: the run went terminal under it, or its `onTimeout` was `fail`. | | `requestedAt`, `expiresAt` | When it was requested, and when its `onTimeout` action fires. `expiresAt` absent without a timeout. | | `decidedAt`, `decidedBy`, `editedArgs` | Set once decided: when, by whom, and the decider's edited arguments (approve-with-edits). Absent while open. | | `decidedVia` | Which surface the decision arrived through. See [Deciding](#deciding). | ## Deciding `POST /approvals/{id}/decision` applies the decision and resumes the run: ```json theme={null} { "status": "approved", "args": { "orderId": "A1", "amount": 2100, "currency": "usd" } } ``` `status` must be `approved` or `denied`. `args`, when present, replaces the proposed tool arguments on the approved decision - approve-with-edits; the workflow receives them as the effective `decision.args`. The response is the decided approval. The decider is recorded from the authenticated caller: the `X-Duraton-Actor` header when a platform-issued key supplies one, otherwise the API key's name. Scope does not grant that - a customer key is full-scope too, and one that could name a person would be signing their name to its own actions. The request body cannot set it, so a decision can never be attributed to someone who did not make it, and the approval record always agrees with the audit log. `decidedVia` records **which surface** the decision arrived through, alongside who made it. It is derived the same way - from how the request authenticated - and is likewise not settable by the body, because the surfaces do not carry the same weight: | `decidedVia` | Recorded when | What it evidences | | ------------ | ------------------------------------------ | ------------------------------------- | | `console` | A platform key naming the acting person | A signed-in person decided | | `mcp` | The request reached `/mcp` | An agent decided with a write tool | | `api` | Any other authenticated call | A credential decided; nobody is named | | `timeout` | The approval's own `onTimeout` resolved it | Nobody decided; the deadline did | | `unrecorded` | Decided before key attribution existed | Nothing is claimed | An auditor asking how a high-risk tool call was cleared needs that difference: the same person clearing a gate from a signed-in session and from a raw API call leaves the same `decidedBy`. A [timeout resolution](/ai/approvals#timeouts-and-escalation) has no caller to derive either field from, so it records `decidedBy: "system:timeout"` alongside `decidedVia: "timeout"` rather than an empty decider that would read as an unattributed person. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const open = await duraton.approvals.list({ status: "pending" }); await duraton.approvals.decide(open[0].id, { status: "approved" }); ``` ```sh theme={null} curl "$DURATON_URL/approvals?status=pending" curl -X POST "$DURATON_URL/approvals/01JZR4A0.../decision" -d '{"status":"approved"}' curl -X POST "$DURATON_URL/approvals/01JZR4A0.../decision" -d '{"status":"denied"}' ``` ## Error codes | Status | When | | ------ | ---------------------------------------------------------------------------------------------------------------- | | `400` | An unknown `status` filter; a decision whose `status` is not `approved`/`denied`; or invalid `args` JSON. | | `404` | The approval id does not exist. | | `409` | Deciding an approval that is no longer open: already decided, already resolved by its `onTimeout`, or cancelled. | # Control API Source: https://docs.duraton.ai/reference/api/control Take control of a run in flight: cancel, pause, resume, replay it, or retry from a step - plain HTTP, with replay and retry forking a new run. Duraton exposes mutating control endpoints alongside the read-only [runs API](/reference/api/runs). Each returns the affected run as JSON (replay and retry-from-step return the **new** run); misuse returns `409 Conflict`, an unknown run `404 Not Found`. ## Endpoints | Method + path | Effect | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `POST /runs/{id}/cancel` | Move a non-terminal run to `cancelled` and cancel its in-flight steps. | | `POST /runs/{id}/pause` | Move a non-terminal run to `paused`. The scheduler will not step a paused run. Idempotent. | | `POST /runs/{id}/resume` | Move a `paused` run back to `queued` and re-enqueue it. | | `POST /runs/{id}/replay` | Start a fresh run from a finished run's trigger. Optional body `{ "input": }` overrides the forked run's payload (absent = replay verbatim). Returns the new run. | | `POST /runs/{id}/retry-from-step` | Fork a finished run from a chosen step, carrying the steps before it. Body `{ "step": "" }`. Returns the new run. | | `POST /runs/bulk-replay` | Replay every finished run matching a filter. Body `{ app?, workflow?, status?, runType?, since? }`. Returns outcome counts. | | `POST /runs/bulk-cancel` | Cancel every non-terminal run matching a filter. Body `{ app?, workflow?, status?, runType?, tags?, since? }`. Returns outcome counts. | ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); await duraton.runs.pause("01HXYZ..."); await duraton.runs.resume("01HXYZ..."); const replayed = await duraton.runs.replay("01HXYZ..."); // the new run const resumed = await duraton.runs.retryFromStep("01HXYZ...", "charge"); // the new run const bulk = await duraton.runs.bulkReplay({ status: "failed", since: "2026-06-01T00:00:00Z" }); ``` ```sh theme={null} curl -X POST $DURATON_URL/runs/01HXYZ.../pause curl -X POST $DURATON_URL/runs/01HXYZ.../resume curl -X POST $DURATON_URL/runs/01HXYZ.../replay curl -X POST $DURATON_URL/runs/01HXYZ.../replay -d '{"input":{"orderId":"A1"}}' # replay with an edited payload curl -X POST $DURATON_URL/runs/01HXYZ.../retry-from-step -d '{"step":"charge"}' curl -X POST $DURATON_URL/runs/bulk-replay -d '{"status":"failed","since":"2026-06-01T00:00:00Z"}' ``` `replay` and `retryFromStep` return the new run, e.g. `{ "id": "01HABC...", "status": "queued" }`. ## Which status accepts which call The [run statuses](/reference/api/runs) split into non-terminal (`queued`, `running`, `waiting`, `paused`, `needs_attention`) and terminal (`succeeded`, `failed`, `cancelled`). Every control call is gated on that split: ```sh theme={null} curl -X POST "$DURATON_URL/runs/01HXYZ.../pause" # 200 while non-terminal, 409 once terminal curl -X POST "$DURATON_URL/runs/01HXYZ.../replay" # 200 once terminal, 409 while non-terminal ``` | Call | Accepted when the run is | Rejected with | | --------------------------- | ------------------------ | ----------------------------------------------------------- | | `cancel`, `pause` | non-terminal | `409` on a terminal run | | `resume` | `paused` | `409` on any other status | | `replay`, `retry-from-step` | terminal | `409` on a non-terminal run | | `bulk-replay` | - | non-terminal matches are counted in `skipped`, not replayed | | `bulk-cancel` | - | terminal matches are counted in `skipped`, not cancelled | `paused` is non-terminal: a paused run holds no worker but stays cancellable and resumable. Pause takes effect at the next **step boundary** - the step in flight finishes and is checkpointed, then the run stops before its next step; it is not a mid-step interrupt. Resume re-queues the run, which continues from the next step and replays the already-completed steps from their stored results, so no prior work runs twice. ## Replay semantics `replay` acts only on a **finished** run (`succeeded` / `failed` / `cancelled`); replaying a still-active run returns `409`. It does not mutate the original - that stays as history. Instead it creates a **new** run with its own id, copying the original's workflow, app, input, and runner, and enqueues it from the start. The new run records the id of the source run it was forked from in `replayOf` (set the same way by `retry-from-step` and `bulk-replay`), so the lineage is traceable both ways: a run links back to its origin, and `GET /runs?replayOf=` lists every run forked from one source. See [replay->run lineage](/reference/api/runs). ## Bulk replay `bulk-replay` redrives many runs at once. It selects finished runs by the same axes as the [runs listing](/reference/api/runs) - `app`, `workflow`, `status`, `runType`, and `since` (project-scoped) - and forks each, newest first, up to a per-call ceiling (the response sets `capped: true` when more matched than were replayed; narrow the filter and call again). Non-terminal matches are counted in `skipped`, not replayed. A request with no filter at all is rejected (`400`), so a replay is always scoped to at least one of `status`/`since`/`app`/`workflow`/`runType` - an empty body never redrives the whole project. The response is `{ matched, replayed, skipped, failed, capped }`. A suspended project refuses the whole call (`403`). ## Bulk cancel `bulk-cancel` is the mirror of bulk replay: it cancels many **non-terminal** runs at once. It selects runs by the same axes as the [runs listing](/reference/api/runs) - `app`, `workflow`, `status`, `runType`, and `since` - plus [`tags`](/reference/api/runs#run-tags), so you can cancel, say, every queued run for one customer. It cancels each match newest first, up to a per-call ceiling (the response sets `capped: true` when more matched than were cancelled; narrow the filter and call again). Already-terminal matches are counted in `skipped`, not cancelled, and a run that finishes between the match and the cancel is skipped too, so the call is idempotent. A request with no filter at all is rejected (`400`) - a cancel is always scoped to at least one of `status`/`since`/`app`/`workflow`/`runType`/`tags`, so an empty body never cancels the whole project. The response is `{ matched, cancelled, skipped, failed, capped }`. A suspended project refuses the whole call (`403`). Each cancelled run fires its own `run.cancelled` webhook. ```ts theme={null} // cancel every queued run for one customer const res = await duraton.runs.bulkCancel({ status: "queued", tags: { customerId: "A1" } }); // { matched, cancelled, skipped, failed, capped } ``` ```sh theme={null} curl -X POST $DURATON_URL/runs/bulk-cancel -d '{"status":"queued","tags":{"customerId":"A1"}}' ``` The `tags` filter depends on [run tags](/reference/api/runs#run-tags). ## Retry from a step `retry-from-step` is replay with a checkpoint. Like replay it acts only on a **finished** run and forks a **new** run (the original stays as history), but it carries over the completed steps **before** the named step and resumes execution from that step. The carried steps replay from their stored results - durable execution skips them - so an expensive earlier step (a charge, an email) is not run twice. Any step of the run is a valid boundary, letting you rewind to any point; picking the first step carries nothing and is equivalent to a full replay. An unknown step name returns `404`. ## Audit log Every state-changing call is recorded to a per-project audit log - the control actions above plus the approval decisions. Read it with `GET /control-actions`, newest first: ```sh theme={null} curl "$DURATON_URL/control-actions?runId=01HXYZ...&limit=100" ``` | Query | Effect | Default | | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | | `action` | One kind: `cancel`, `pause`, `resume`, `replay`, `retry_from_step`, `bulk_replay`, `bulk_cancel`, `approve`, `deny`. An unknown value returns `400`. | all kinds | | `runId` | Entries targeting that run **or** forking into it - a run's full control history in one query. | all runs | | `limit` | Page size, `1`-`1000`. A non-integer returns `400`. | `100` | Each entry is `{ id, action, runId?, newRunId?, actor?, detail?, createdAt }`. `actor` is the **name of the API key** the call authenticated with - so naming your keys per surface (`ci`, `support-tool`) is what makes the log attributable. `detail` carries action-specific context: the `step` a retry resumed from, or a bulk replay's filter and outcome counts. Recording is best-effort - a failed audit write is logged but never fails the action it describes, since the action has already happened. ## Error codes | Status | When | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `retry-from-step` with no `step`; `bulk-replay`/`bulk-cancel` with an unparseable body or no filter at all; or a `bulk-cancel` `tags` filter that exceeds the [tag limits](/reference/api/runs#run-tags). | | `403` | The project is suspended (any replay path). | | `404` | The run id does not exist; or `retry-from-step` named a step the run does not have. | | `409` | `pause`/`cancel` on a terminal run; `resume` on a run that isn't paused; `replay`/`retry-from-step` on a run that isn't finished. | # Errors Source: https://docs.duraton.ai/reference/api/errors Work out what went wrong and what to do about it: the error body, the status codes the API returns, and how to handle each in the SDK and over MCP. Every failed request returns a JSON body with an `error` field and an HTTP status code: ```json theme={null} { "error": "run already in a terminal state" } ``` The status code tells you the class of failure; the `error` string is a short, human-readable reason. ## Success codes ```sh theme={null} curl -sS -o /dev/null -w "%{http_code}\n" -X POST "$DURATON_URL/events" \ -H "Authorization: Bearer $DURATON_API_KEY" \ -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421"}}' # 202 ``` | Status | Returned by | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `200 OK` | Every read, the control writes (`cancel`, `pause`, `resume`, `replay`, `retry-from-step`, `bulk-replay`), an approval decision, a `PATCH` on a webhook endpoint/source, and `POST /webhook-source-deliveries/{id}/replay` (returns the replay outcome). | | `201 Created` | `POST /webhook-endpoints`, `POST /webhook-sources`. | | `202 Accepted` | `POST /events` and `POST /webhooks/{token}` - the event is recorded; the runs it starts are asynchronous. | | `204 No Content` | `DELETE /webhook-endpoints/{id}`, `DELETE /webhook-sources/{id}`, `POST /webhook-deliveries/{id}/redeliver`. | ## Status codes | Status | Meaning | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400 Bad Request` | The request body is malformed, or a field is invalid - an event names a workflow that isn't registered, an event carries an [unsupported character](/reference/api/events#unsupported-characters), an approval decision isn't `approved`/`denied`, or a `GET /webhook-source-deliveries?status=` value is outside the [accepted set](/reference/api/webhooks#the-inbound-delivery-log). | | `401 Unauthorized` | The API key is missing or unknown. | | `403 Forbidden` | A read-only (public) key attempted a write, or the project is suspended after its workspace reached the plan limit. | | `404 Not Found` | The addressed resource - run, step, event, approval, or webhook source/endpoint/delivery - does not exist. | | `409 Conflict` | The request conflicts with current state (see below). | | `501 Not Implemented` | The requested capability isn't configured for this project (for example, failure explanations when no model is wired up). | | `502 Bad Gateway` | The status reserved for a runner-not-registered condition. Note that triggering a run against an app with no live [runner](/core/runners) does **not** return this: `POST /events` is still accepted (`202`) and the run [parks and retries](/core/runners#when-no-runner-is-registered), failing terminally only after the bounded no-runner wait elapses. | | `500 Internal Server Error` | An unexpected fault. The body is a generic `{ "error": "internal error" }`; the detail is logged server-side and never returned. | ## Conflicts (409) A `409` means the resource is in a state that doesn't allow the operation. The common cases: | `error` | Cause | | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `run already in a terminal state` | Pausing, resuming, or cancelling a run that already succeeded, failed, or was cancelled. | | `run is not in a terminal state` | Replaying a run that hasn't finished. | | `run is not paused` | Resuming a run that isn't paused. | | `run is not awaiting a decision` | Deciding an approval on a run that isn't waiting for one. | | `approval is not open` | Approving or denying an approval that was already decided. | | `step attempt already recorded` | A duplicate step result (a runner retried after Duraton already recorded the step). | | `webhook token already in use` | Creating an inbound source whose issued receive URL token collides (rare - the token is random). | | `webhook source name already in use` / `webhook endpoint name already in use` | A source/endpoint name isn't unique in the project. | | `webhook endpoint url already subscribed` | The outbound URL is already registered in the project. | | `webhook delivery is in flight` | Redelivering a webhook whose current attempt hasn't settled. | Conflicts are safe to surface to the caller and, where they reflect a race (a duplicate step, an in-flight delivery), safe to ignore. ## Handling errors Check the status code and read the `error` field: ```sh theme={null} curl -sS -o /tmp/body -w "%{http_code}" \ -X POST "$DURATON_URL/runs/$RUN_ID/resume" \ -H "Authorization: Bearer $DURATON_API_KEY" # 409 cat /tmp/body # { "error": "run is not paused" } ``` The client throws `DuratonApiError`, which carries the `status` and raw `body` and has helpers for the common classes: ```ts theme={null} import { createClient, DuratonApiError } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY!, }); try { await duraton.runs.resume(runId); } catch (err) { if (err instanceof DuratonApiError) { if (err.isConflict()) { // 409: the run wasn't paused - already resumed or finished } else if (err.isNotFound()) { // 404 } else { console.error(err.status, err.body); } // also: err.isBadRequest(), err.isUnauthorized(), err.isForbidden() } } ``` When a tool call fails, the [MCP server](/integrations/mcp-server) returns the same reason as the tool result's error - for example, calling `resume_run` on a run that isn't paused returns `run is not paused`. Unexpected server faults are masked to `internal error`, exactly as over REST, and a write tool called with a read-only key returns `this tool requires a full-access (secret) API key`. # Events API Source: https://docs.duraton.ai/reference/api/events Trigger workflows by sending an event, and read the durable event log. An event is how you start a workflow from outside Duraton. `POST /events` ingests one event; Duraton matches it against every workflow [trigger](/core/triggers) in the project and returns what it started. Every ingested event is also kept in a durable [event log](/core/workflows) you can read and tail. The live event log in the console ## Send an event ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const res = await duraton.events.send({ name: "ticket.created", app: "support-app", data: { ticketId: "T-421" }, }); ``` ```sh theme={null} curl -X POST $DURATON_URL/events \ -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421"}}' ``` ### Request body | Field | Type | Meaning | | ----------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | string, **required** | The event name workflows [trigger](/core/triggers) on. Non-blank, up to 256 characters. | | `app` | string | Scope the event to one app's triggers. Omit to broadcast project-wide. | | `data` | any JSON | The event payload, delivered as `ctx.event.data`. Any valid JSON - object, array, or scalar. | | `dedupeId` | string | Drop a repeat of the same id (per app) within 24 hours, before any fan-out. | | `runner` | string | Pin runs started by this event to a specific runner. | | `targetApp` | string | Route a cross-app trigger to a specific app. | | `session` | string | Thread the runs this event starts into one conversation, read back by [`GET /sessions`](/reference/api/runs#ai-spend-&-sessions). Omitted, each run is its own session. | | `tags` | object of string->string | Customer-defined key/value metadata attached to every run this event triggers, for filtering runs by your own dimensions (see [Run tags](/reference/api/runs#run-tags)). | `app`, `runner`, `targetApp`, `dedupeId`, and `session` are each bounded at 256 characters; a longer value, a blank `name`, or unparseable `data` returns `400`. `tags` is bounded too: at most 20 tags per run, each key up to 64 characters matching `[A-Za-z0-9_.-]` and each value up to 256 characters - an over-limit tag returns `400`. `tags` on an event is sendable from the TypeScript SDK - `SendEventInput` carries a `tags` field - as well as over REST. The tags attach to every run the event triggers; filter those runs back with `tag.` (see [Run tags](/reference/api/runs#run-tags)). ### Unsupported characters An event must not carry a NUL character (`\u0000`). Duraton's durable event log has no representation for it, so an event carrying one could never be stored. Duraton rejects it at ingest with a `400` - before any workflow is matched or any run is started - rather than accepting the event and failing later. The check covers every text field (`name`, `app`, `runner`, `dedupeId`, `targetApp`, `session`) and the whole `data` payload, at any depth: a NUL inside a nested string, an array element, or even a JSON object key is rejected. ```sh theme={null} curl -X POST $DURATON_URL/events \ -d '{"name":"ticket.created","app":"support-app","data":{"note":"bad\u0000byte"}}' # 400 ``` Only the escaped form `\u0000` reaches this check - a raw NUL byte in the request body is already invalid JSON and is rejected as a malformed body. Either way the event is refused with a `400`, and nothing is recorded. ### Response `202 Accepted`. The body reports what the event did: ```json theme={null} { "runId": "01HXYZ...", "woke": 0, "triggered": [ { "workflow": "fulfillment", "runId": "01H..." }, { "workflow": "audit", "runId": "01H..." } ] } ``` | Field | Meaning | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `runId` | The run started, when exactly one workflow matched. | | `woke` | How many waiting runs this event resumed (via `step.waitForEvent`). | | `triggered` | One entry per matched workflow: its `workflow` name, the `app` the run landed in (set even for cross-app fan-out), and the `runId` started. | | `skipped` / `dropped` / `debounced` / `batched` / `deduped` | Flow-control outcomes - set when the event was held back rather than run immediately (see [flow control](/core/flow-control)). | | `suspended` | `true` when the project is suspended: in-flight waiters were still woken, but no new run was started. | An event that matches nothing still returns `202` with an empty `triggered` - it is recorded, not lost. Each `triggered` entry's `runId` resolves to a run, and that run records the event back: its `eventId` is this event's `id` (see [runs](/reference/api/runs)). List every run one event fanned out with `GET /runs?eventId=`. Cron, child (`step.runWorkflow`), and on-failure runs carry no `eventId` - they have no triggering event. ## Two kinds of deduplication Two independent mechanisms can each report `deduped: true`, and they mean different things. Knowing which one fired matters, because one throws the event away and the other only suppresses a single run. | | Event `dedupeId` | Workflow [`idempotency`](/core/flow-control#idempotency) | | ---------------------------------------------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | Set where | On the event (`dedupeId` on `POST /events`) | On the workflow definition (`idempotency: { key, periodMs }`) | | Keyed by | The literal `dedupeId` string you send, scoped **per app** | A **field path** into the event `data` (e.g. `key: "ticketId"`), scoped **per workflow** | | Window | Fixed **24 hours** | The configured `periodMs` | | What is dropped | The **whole event**, before any fan-out | One **run** of that one workflow | | Waiters woken? | No - a duplicate is dropped before `waitForEvent` waiters are checked | Yes - waiters are woken before the per-workflow gate runs | | Recorded in the [event log](#reading-the-log)? | No - a duplicate is never persisted | Yes - the event is recorded, only the run is suppressed | | Where `deduped` appears | At the top level (`{ "deduped": true }`, no `runId`, no `triggered`) | On that workflow's `triggered[]` entry (mirrored to the top level when it is the first match) | In short: **`dedupeId` is event-level and total** - the exact same event is collapsed to a no-op, nothing is recorded, and nothing is woken. It is the safety net for an at-least-once caller retrying `POST /events`. **`idempotency` is run-level and partial** - the event still lands, still wakes waiters, and still runs every *other* matching workflow; only a second run of the idempotent workflow for the same derived key is suppressed within the window. Because both surface `deduped: true`, tell them apart by the rest of the response: an event-level drop returns no `triggered` array and no `runId`, whereas an idempotency drop still carries the event's `triggered` fan-out with `deduped` set on the affected entry. ## Reading the log The log records every event with what it did, newest first. ```sh theme={null} curl "$DURATON_URL/events?app=support-app&name=ticket.created&limit=20" ``` | Method + path | Returns | | -------------------- | -------------------------------------------------------------------------------------------------------------------- | | `GET /events` | A page of event records. Filter with `?app=` and `?name=`; `?limit=` sets the page size (`100` default, `1000` max). | | `GET /events/{id}` | One event record. | | `GET /events/stream` | A live tail of incoming events (Server-Sent Events). | Each record carries the event (`name`, `app`, `data`), its `source` (`api` for an external `POST`, `emit` for a workflow's `step.emit`), when it arrived, and the `triggered` fan-out: ```json theme={null} { "id": "9f2b...", "name": "ticket.created", "app": "support-app", "source": "api", "data": { "ticketId": "T-421" }, "receivedAt": "2026-06-15T09:00:00Z", "woke": 0, "triggered": [{ "workflow": "fulfillment", "runId": "01H..." }] } ``` The stream is a best-effort live view - a slow or reconnecting client can miss events. `GET /events` is the complete record. # Overview Source: https://docs.duraton.ai/reference/api/index Do anything the console does from your own code: the Duraton HTTP API's base URL, authentication, conventions, and full endpoint map. Everything the console does goes through Duraton's HTTP API, and so can you. It is plain JSON over HTTP - no SDK required to trigger events, read runs, or control them. ```sh theme={null} curl "$DURATON_URL/runs?limit=5" \ -H "Authorization: Bearer $DURATON_API_KEY" ``` ## Base URL `DURATON_URL` is the Duraton API base: `https://run.duraton.dev`. All paths below are relative to it. ## Authentication Every request needs an API key, presented as a Bearer token. Issue one in the console under **API Keys**: ```sh theme={null} curl "$DURATON_URL/runs" \ -H "Authorization: Bearer $DURATON_API_KEY" ``` Keys carry a scope. A **public** (read-only) key can call the `GET` endpoints; writes (every `POST`, `PATCH`, and `DELETE`, plus the `/connect` upgrade) need a **secret** key. | Response | When | | ------------------ | ------------------------------------------------------------------------ | | `401 Unauthorized` | The key is missing or unknown. | | `403 Forbidden` | A public (read-only) key attempted a write, or the project is suspended. | ## Conventions * Request and response bodies are JSON. Send `POST` and `PATCH` bodies as a JSON object. * Reads return `200`; writes return `200`, `201`, `202`, or `204` depending on the route. The full map, and the failure codes, are in [errors](/reference/api/errors). * Listings are newest-first and bounded. `GET /runs`, `GET /webhook-deliveries`, and `GET /webhook-source-deliveries` use [keyset pagination](/reference/api/runs#keyset-pagination) via the `X-Next-Cursor` header; the other listings take a `limit` (see [limits](/reference/api/limits)). ## Runs | Method + path | Purpose | | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | `GET /runs` | [List and filter runs](/reference/api/runs), newest first, with an `X-Next-Cursor` header. | | `GET /runs/{id}` | One run, with its input and result. | | `GET /runs/{id}/steps` | The run's steps, in order. | | `GET /runs/{id}/logs` | The run's [`ctx.log` lines](/reference/api/runs#run-logs), oldest first. | | `GET /runs/{id}/stream` | [Live SSE timeline](/reference/api/runs#live-run-stream) of one run (status + logs). | | `GET /runs/stream` | [Live SSE stream](/reference/api/runs#project-wide-run-stream) of run status transitions across the project. | | `GET /runs/stats` | [Aggregate run counts](/reference/api/runs#run-stats) for a filter. | | `GET /runs/timeseries` | [Run counts bucketed over time](/reference/api/runs#run-time-series), with duration averages. | | `GET /ai/spend` | [AI token/cost rollup](/reference/api/runs#ai-spend-&-sessions) - totals plus by-hour, by-model, by-workflow. | | `GET /sessions` | [Conversation sessions](/reference/api/runs#ai-spend-&-sessions): runs grouped by session id. | ## Control | Method + path | Purpose | | --------------------------------- | ---------------------------------------------------------------------------------- | | `POST /runs/{id}/cancel` | [Cancel](/reference/api/control) a run. | | `POST /runs/{id}/pause` | Pause a run at its next step boundary. | | `POST /runs/{id}/resume` | Resume a paused run. | | `POST /runs/{id}/replay` | Replay a finished run as a new run. | | `POST /runs/{id}/retry-from-step` | [Fork a finished run from a named step](/reference/api/control#retry-from-a-step). | | `POST /runs/bulk-replay` | [Replay every finished run matching a filter](/reference/api/control#bulk-replay). | | `GET /control-actions` | [The control-action audit log](/reference/api/control#audit-log), newest first. | ## Events | Method + path | Purpose | | -------------------- | ------------------------------------------------------------------------------ | | `POST /events` | [Trigger workflows](/reference/api/events) by sending an event. Returns `202`. | | `GET /events` | [Read the event log](/reference/api/events#reading-the-log). | | `GET /events/{id}` | One event log record. | | `GET /events/stream` | Live event tail (Server-Sent Events). | ## Approvals | Method + path | Purpose | | ------------------------------- | ------------------------------------------------------------------ | | `GET /approvals` | [The project's approvals](/reference/api/approvals), newest first. | | `GET /approvals/{id}` | One approval, with its proposed tool call. | | `POST /approvals/{id}/decision` | Approve or deny an open approval; the parked run resumes. | ## Webhooks | Method + path | Purpose | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `POST /webhooks/{token}` | Public: a [signature-verified inbound webhook](/integrations/webhooks) mapped onto an event. Returns `202`. | | `GET /webhook-deliveries` | [The outbound delivery log](/reference/api/webhooks), newest first. | | `GET /webhook-deliveries/{id}` | One delivery with its attempt log. | | `POST /webhook-deliveries/{id}/redeliver` | Re-queue a delivery for a fresh attempt. Returns `204`. | | `GET /webhook-source-deliveries` | [The inbound source delivery log](/reference/api/webhooks#the-inbound-delivery-log), newest first. | | `GET /webhook-source-deliveries/{id}` | One inbound delivery with its stored body and attempt log. | | `POST /webhook-source-deliveries/{id}/replay` | Re-ingest a verified inbound delivery's stored body. Returns `200` with the replay outcome. | | `GET` `POST` `PATCH` `DELETE /webhook-endpoints[/{id}]` | [Manage outbound subscriptions](/reference/api/webhooks#managing-endpoints-and-sources) (secrets shown once). | | `GET /webhook-endpoints/stats` | Per-endpoint delivery health over a window. | | `GET` `POST` `PATCH` `DELETE /webhook-sources[/{id}]` | Manage inbound sources (secrets shown once). | ## Registry | Method + path | Purpose | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GET /workflows` | List registered workflows, each with its `flowControl` config and the advisory `steps` manifest when declared. | | `POST /workflows/{app}/{name}/trigger` | [Start one off-schedule run](/reference/api/workflows#trigger-a-run-manually) of a registered workflow - works even for a cron-only workflow. Returns `202`. | | `GET /apps` | [List registered apps](/reference/api/runners#get-/apps). | | `GET /runners` | [List connected runners](/reference/api/runners) with their metadata and liveness. | | `GET /flow-state` | [Live flow-control buffers](/core/flow-control#observing-flow-control) (debounce, batch, concurrency draw). | The runner protocol (`POST /register`, the invoke call, `GET /connect`) is documented separately in the [protocol reference](/reference/wire-protocol), and the same operations are available to AI agents as [MCP tools](/integrations/mcp-server). # Limits & defaults Source: https://docs.duraton.ai/reference/api/limits Know the number before you hit it: every default and ceiling Duraton applies to pagination, request size, retries, AI steps, and connections. A reference for the numbers Duraton applies when you don't set one. Where a value is configurable, the option is linked. ## Requests & pagination | Limit | Value | Notes | | ---------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Request body size | 1 MiB | Applies to every JSON request body and inbound webhook payload. Larger bodies are rejected. | | Cursor page size | `30` default, `200` max | `GET /runs`, `GET /webhook-deliveries`, and `GET /webhook-source-deliveries`. Set `limit` to change it; over `200` is clamped to `200`. | | Log & list page size | `100` default, `1000` max | `GET /events`, `/approvals`, `/control-actions`, and a run's `logs`. Over `1000` is clamped to `1000`. | | Session page size | `100` default, `500` max | `GET /sessions`. Over `500` is clamped to `500`. | | Unpaged reads | whole list | A run's `steps` take no `limit` - they return every row for that run. | | Pagination cursor | `X-Next-Cursor` header | `GET /runs`, `/webhook-deliveries`, and `/webhook-source-deliveries` use [keyset pagination](/reference/api/runs#keyset-pagination); pass the returned cursor to fetch the next page. | | Event name / id length | 256 characters | Applies to `name`, `app`, `runner`, `targetApp`, `dedupeId`, and `session` on `POST /events`. Over the bound returns `400`. | ## API rate limits Duraton does **not** impose a fixed per-second request rate limit on the API. There is no requests/second quota on `POST /events` or on the read endpoints, and no `429 Too Many Requests` / `Retry-After` back-off protocol to code against - a well-formed request is admitted regardless of how many preceded it. Two real ceilings apply instead: | Ceiling | Effect | | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Request body size (1 MiB) | A larger JSON body or inbound webhook payload is rejected outright (see above). | | Usage / plan limit | When a workspace reaches its plan's metered ceiling the project is **suspended**, and subsequent run-starting calls are refused with [`403 Forbidden`](/reference/api/errors) rather than throttled. | Suspension is the ceiling that actually gates throughput, and it is not a rate limit - it is a hard stop until usage falls back under the plan or the plan is raised. Its shape differs by endpoint: * **`POST /events` on a suspended project** still returns `202 Accepted`, but starts no new run: the response carries `suspended: true` with no `runId`, and any in-flight `waitForEvent` waiters are still woken. The event is recorded; only the fan-out into new runs is withheld. * Other run-creating actions (and cron-triggered runs) on a suspended project are refused with `403 Forbidden`. Because there is no request-rate throttle, protect a busy ingest path with your own client-side batching or concurrency limit; the durable back-pressure you *can* configure is per-workflow [flow control](/core/flow-control) (concurrency, throttle, rate-limit, debounce, batch), which shapes how fast admitted events turn into runs. ## Retries & delivery | Limit | Value | Configurable | | ------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | Step retries | none by default (`maxAttempts: 1`) | Per workflow via [`retry`](/core/retries). A failed step waits a backoff delay, then retries up to `maxAttempts` total. | | Outbound webhook attempts | up to `5` | Duraton retries a [failed delivery](/integrations/webhooks) with a backoff between attempts, then marks it `exhausted`. | | Outbound webhook timeout | `10s` per attempt | A slower endpoint counts as a failed attempt. | | Webhook signing | HMAC-SHA256 | The `X-Duraton-Signature` header on every inbound and outbound delivery. | ## AI steps | Default | Value | Option | | --------------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Provider | `anthropic` | [`provider`](/reference/sdk/ai-steps) on `step.ai.generate`. | | Re-asks on invalid output | `1` | [`reask`](/reference/sdk/ai-steps); applies only when an `output` schema is set. | | Inference cache TTL | 24 hours | [`cache`](/reference/sdk/ai-steps) - pass `{ ttlMs }` to change it. | | Inference cache eligibility | `temperature` set and `<= 0.2` | The cache is a no-op when `temperature` is above `0.2` **or** left unset (a provider default is often non-deterministic), so only near-deterministic calls are reused. | | Embedding batch size | `100` inputs per durable batch | [`batchSize`](/reference/sdk/ai-steps) on `step.ai.embed`. | Per-run AI spend ceilings are opt-in and unlimited by default - see [`cap`](/ai/cost-controls). ## Connection defaults These are what the SDK uses when a value and its environment variable are both unset: | Setting | Default | Environment variable | | ------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------- | | `url` | `https://run.duraton.dev` | `DURATON_URL` | | `apiKey` | none - required on every request | `DURATON_API_KEY` | | App | `default` | `DURATON_APP` | | Runner heartbeat interval | `30s` | - (a [`connect`](/reference/sdk/connect) endpoint is refreshed on its socket heartbeat) | | Runner liveness TTL | `90s` since the last heartbeat | - (a runner past it shows as **Stale** and is listed with `live: false`) | | Protocol version | `1` | - (checked in the Connect handshake; see the [protocol reference](/reference/wire-protocol#protocol-version)) | # Runners & Apps API Source: https://docs.duraton.ai/reference/api/runners See which apps and runners are live right now - they appear because a runner registered itself, with its self-reported metadata attached. The read-only runners and apps API backs the console's **Apps** view. It reflects **live registrations** - apps and runners appear because a runner registered, not because anything was configured by hand. There is no write surface here: runners register themselves through the SDK handshake (see [runners](/core/runners)). | Method + path | Returns | | -------------- | ---------------------------------------------------------------------- | | `GET /apps` | The project's registered apps. | | `GET /runners` | The project's registered runner endpoints, with metadata and liveness. | ## GET /apps Lists every app that has registered in the project, ordered by name. | Field | Meaning | | ----------- | --------------------------------------- | | `name` | The app name. | | `createdAt` | When the app was first seen (ISO 8601). | ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const apps = await duraton.apps.list(); // App[] ``` ```sh theme={null} curl $DURATON_URL/apps ``` ## GET /runners Lists the registered runner endpoints. Pass `?app=` to scope to one app; omit it for every app in the project. | Param | Meaning | Default | | ----- | -------------------- | -------- | | `app` | Restrict to one app. | all apps | Each runner carries the metadata it reported on register. Optional fields are present only when the runner actually reported them (a runner predating a field, or one that left it unset, omits it). | Field | Meaning | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `runnerId` | The runner's stable id, or its URL when it registered without one. | | `app` | The app this runner hosts. | | `url` | A `conn://` pseudo-URL naming the runner's WebSocket connection. | | `framework` | `connect`. | | `runtime` | The JS runtime: `node`, `bun`, or `deno`. | | `sdkName` + `version` | The SDK package and version. | | `region` | The deployment region, when `DURATON_REGION` is set. | | `lastSeenAt` | When Duraton last saw this runner (ISO 8601): its last socket heartbeat. It advances roughly every `30s` while the runner is healthy. | | `live` | Whether `lastSeenAt` is inside the `90s` liveness window, so a runner whose process is gone reports `live: false` once it falls behind. A runner past the window is listed, not hidden. | See [runner liveness](/core/runners#liveness) for what refreshes `lastSeenAt`, and what a `live: false` runner means for routing. Responses never include a key or secret. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const runners = await duraton.runners.list({ app: "shop" }); // Runner[] ``` ```sh theme={null} curl "$DURATON_URL/runners?app=shop" ``` ```json theme={null} [ { "runnerId": "gateway-1", "app": "shop", "url": "conn://01HXYZ...", "framework": "connect", "runtime": "node", "sdkName": "@duraton/sdk", "version": "0.1.0", "region": "us-east-1", "lastSeenAt": "2026-06-25T10:30:00Z", "live": true } ] ``` # Runs API Source: https://docs.duraton.ai/reference/api/runs Find any run and see what it did: list, filter, paginate, and summarize runs, and read one run's steps, logs, and AI spend. The read-only runs API backs the console's Runs views. Mutating actions (cancel, pause, resume, replay) live in the [control API](/reference/api/control). A run's detail in the console | Method + path | Returns | | ----------------------- | --------------------------------------------------------------------------------- | | `GET /runs` | A page of run summaries, newest first, plus an `X-Next-Cursor` header. | | `GET /runs/{id}` | One run, including its `input` and `result`. | | `GET /runs/{id}/steps` | The run's steps, in order. | | `GET /runs/{id}/logs` | The run's captured `ctx.log` lines, in order. | | `GET /runs/{id}/stream` | A live SSE stream of the run's timeline (status + logs). | | `GET /runs/stream` | A live SSE stream of run status transitions across the project. | | `GET /runs/stats` | Aggregate counts plus p50/p95 latency for the current filter. | | `GET /runs/timeseries` | Run counts bucketed over time, with per-bucket duration averages and percentiles. | | `GET /ai/spend` | AI token/cost rollup - totals plus by-hour, by-model, by-workflow. | | `GET /sessions` | Conversation sessions: runs grouped by session id, with per-session rollups. | ## Listing & filtering `GET /runs` accepts these query parameters: | Param | Meaning | Default | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------- | | `app` | Restrict to one app. | all apps | | `workflow` | Restrict to one workflow name. | all workflows | | `status` | One run status: `queued`, `running`, `waiting`, `paused`, `needs_attention`, `succeeded`, `failed`, `cancelled` - each matches exactly (`running` is a run actively executing a step; `waiting` is a run parked on a retry backoff, sleep, event, or child). Two aliases group them: `active` selects the in-flight set (`queued` + `running` + `waiting`), and `all` means no status filter, identical to omitting the param. | all | | `runType` | One [trigger kind](/core/triggers#trigger-kinds): `event`, `cron`, or `manual`. | all | | `eventId` | Restrict to the runs one event fanned out (event->run lineage). | all | | `replayOf` | Restrict to the runs forked from one source run by replay / retry-from-step (replay->run lineage). | all | | `parentRunId` | Restrict to the direct children of one run - the runs its `step.runWorkflow` calls spawned (call->run lineage). One nesting level, never grandchildren. | all | | `session` | Restrict to one conversation session id (the `session` sent on the event). | all | | `tag.` | Restrict to runs carrying that [tag](#run-tags) with the given value, e.g. `tag.customerId=abc`. Repeatable across keys; multiple tag filters are ANDed. Index-backed. | all | | `q` | Substring match on workflow name, run id, or app. | none | | `deep` | `1` to widen `q` to also match run input, result, and error content (a case-insensitive substring scan); ignored without `q`. | off | | `since` | RFC3339 timestamp; only runs started at or after it. | no lower bound | | `sort` | `started`, `workflow`, `status`, `app`, or `duration`. | `started` | | `dir` | `asc` or `desc`. | `desc` (newest first) | | `limit` | Page size, `1`-`200`. | `30` | | `cursor` | Opaque keyset cursor from a previous page (see below). | none | `deep=1` widens the scan from indexed run metadata to the run's stored JSON, so bound it: the payload scan runs inside whatever other filters you apply (`since`, `status`, `app`), and on a busy project a narrow time window is the difference between an index lookup and a full scan. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const page = await duraton.runs.list({ app: "demo", status: "failed", sort: "duration", dir: "desc", limit: 20 }); page.runs; // Run[] page.nextCursor; // pass back as { cursor } for the next page ``` ```sh theme={null} curl "$DURATON_URL/runs?app=demo&status=failed&sort=duration&dir=desc&limit=20" ``` Each run carries `triggerKind` - one of `event`, `cron`, or `manual` (see [trigger kinds](/core/triggers#trigger-kinds)) - so a scheduled or manually-started run is distinguishable both in the list and on the run object. An event-triggered run also carries `eventName` and `eventId` - the event that fanned it out (event->run lineage). `eventId` matches the event log's `id`, so `GET /runs?eventId=` returns every run one event produced, and a run links back to its exact event. Cron, child (`step.runWorkflow`), and on-failure runs have no `eventId`. A run created by [replay or retry-from-step](/reference/api/control) carries `replayOf` - the id of the source run it was forked from (replay->run lineage). `GET /runs?replayOf=` returns every run forked from one source, and the new run links back to its origin. Original runs have no `replayOf`. A run spawned by a `step.runWorkflow` call - a [workflow-backed agent tool](/agent-kit/agents-and-tools) included - carries the call edge it was spawned on (call->run lineage): `parentRunId` (the run that spawned it), `parentStep` (the step it was spawned on) and `parentAttempt` (which attempt of that step spawned it). The spawning step carries `childRunId` back, so the edge is followable both ways. `GET /runs?parentRunId=` returns every child one run started - exact equality on one nesting level, so direct children only, never grandchildren. Top-level runs carry none of the three, and neither does a run triggered by an event another run emitted: only a `runWorkflow` call sets this edge. Every run also carries `depth`, its **causal** nesting, absent (`0`) at the top. A `runWorkflow` child is its parent's depth + 1, and an [emit-triggered run](/core/triggers) is the emitting run's + 1 - so a run can sit at `depth > 0` with no `parentRunId` at all. ## Run tags Tags are customer-defined key/value metadata you attach to a run so you can slice runs by your own dimensions - `customerId`, `region`, `plan`, `batchId`, whatever you name. Every run carries its tags as a `tags` object on the run, and `GET /runs?tag.=` filters by them, index-backed (no full scan). The same filter applies to [`GET /runs/stats`](#run-stats). **Set tags in two places:** * On an event (`events.send` / `POST /events`): the event's tags attach to **every run it triggers** - natural for "tag all runs from this webhook / customer". * On a child run (`step.runWorkflow`): the tags apply to that child run. **Inheritance.** A `step.runWorkflow` child inherits its parent run's tags and merges its own on top, with the child's value winning on a shared key. So a `tag.customerId=X` set on the top-level event also matches the child runs that run spawned - "show all work for customer X" spans the whole tree. **Limits.** At most 20 tags per run; each key up to 64 characters matching `[A-Za-z0-9_.-]`; each value up to 256 characters. An over-limit or malformed tag is rejected with a `400` (it is never silently truncated). ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); // Tag every run this event triggers. await duraton.events.send({ name: "ticket.created", app: "support-app", data: { ticketId: "T-421" }, tags: { customerId: "cus_42", region: "eu" }, }); // Filter runs by tag (AND across keys). const page = await duraton.runs.list({ tags: { customerId: "cus_42", region: "eu" } }); ``` Inside a workflow, tag a child run - it also inherits the parent's tags: ```ts theme={null} await ctx.step.runWorkflow("enrich", { name: "ticket.enrich", data: { ticketId: "T-421" }, tags: { stage: "enrich" }, // + inherited customerId/region from the parent }); ``` ```sh theme={null} # tag every run this event triggers curl -X POST $DURATON_URL/events \ -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421"},"tags":{"customerId":"cus_42","region":"eu"}}' # filter runs by tag (repeatable, ANDed) curl "$DURATON_URL/runs?tag.customerId=cus_42&tag.region=eu" ``` Sending `tags` on an event and the `tag.` runs filter are both supported in the TypeScript SDK. The wire contract (`tags` on an event, `tag.` on the runs filter) is stable, so any language can use tags over REST. ## Look up a single run When you know a run by a **business key** rather than its Duraton run id - "the run for `ticketId=123`" - tag it at trigger time and look it up by that tag. This is just the list query with `limit=1`: the filters (`tag.`, `app`, `workflow`, `status`, `session`) narrow the set, and `sort`/`dir` decide **which** run you get when several match. There is no separate lookup endpoint - `GET /runs` already does it - and no "ambiguous match" error: the newest match wins by default, and you pick a different one by changing `sort`/`dir`. The TypeScript SDK wraps this as `runs.find(opts)`, which returns the one matching run or `null`: ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); // The most recent run tagged ticketId=123 (or null if there is none). const run = await duraton.runs.find({ tags: { ticketId: "123" } }); // Narrow further, and pick the oldest match instead of the newest. const first = await duraton.runs.find({ tags: { ticketId: "123" }, app: "support-app", status: "failed", dir: "asc", }); ``` ```sh theme={null} # newest run tagged ticketId=123; the body is an array (empty if no match) curl "$DURATON_URL/runs?tag.ticketId=123&limit=1" ``` **For AI agents:** the same lookup is an MCP tool, `find_run` - call it with `tags` (plus optional `app`/`workflow`/`status` and `sort`/`dir`) to get one run back as `{ found, run }`. It sits beside `get_run` (by id) and `list_runs` (the full page) in the engine MCP tool list, so an agent can discover and use it without knowing a run id. ## Keyset pagination Paging is **cursor-based (keyset)**, not offset-based. Each response carries up to `limit` run summaries plus an `X-Next-Cursor` header when more rows exist; pass that value back as `?cursor=` to fetch the next (older) page: ```sh theme={null} # first page - read the X-Next-Cursor response header curl -sD - "$DURATON_URL/runs?limit=2" -o /dev/null | grep -i x-next-cursor # next page curl "$DURATON_URL/runs?limit=2&cursor=" ``` The cursor encodes the current sort position, so paging stays correct while new runs arrive - no rows are skipped or repeated the way offset paging drifts under concurrent inserts. The header is **absent on the last page**. A cursor is tied to the `sort`/`dir` it was issued for; changing either invalidates it, so start a fresh page when the ordering changes. ## Run steps `GET /runs/{id}/steps` returns the run's [steps](/core/steps), ordered by execution position and then attempt. It takes no `limit` - every recorded step row for the run is returned. This is the read model behind a progress view and the run-detail step list. ```json theme={null} [ { "name": "validate", "index": 0, "attempt": 1, "status": "succeeded", "input": { "ticketId": "T-421" }, "output": { "ok": true }, "startedAt": "2026-06-22T10:00:00Z", "endedAt": "2026-06-22T10:00:00Z", "durationMs": 12 }, { "name": "triage", "index": 1, "attempt": 1, "status": "failed", "error": { "message": "gateway timeout" }, "willRetry": true, "runAfter": "2026-06-22T10:00:05Z", "nextAttemptAt": "2026-06-22T10:00:05Z", "startedAt": "2026-06-22T10:00:00Z", "endedAt": "2026-06-22T10:00:00Z", "durationMs": 30000 }, { "name": "triage", "index": 1, "attempt": 2, "status": "succeeded", "output": { "refundId": "re_9" }, "startedAt": "2026-06-22T10:00:05Z", "endedAt": "2026-06-22T10:00:05Z", "durationMs": 240 }, { "name": "notify", "index": 2, "attempt": 1, "status": "skipped", "output": "customer opted out" } ] ``` | Field | Meaning | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | The step id (the first argument to `ctx.step.*`). Stable across attempts and across passes. | | `index` | The step's **0-based position** in the run's execution order. All attempts of one step share the same `index`; a [reused step id](/core/steps) (`x`, `x:1`, `x:2`) resolves each distinct name to its own `index`. | | `attempt` | The **1-based attempt number** this row records. A retried step produces one row per attempt (see below). | | `status` | `running`, `waiting`, `succeeded`, `failed`, `skipped`, or `cancelled`. `waiting` is a step parked on a sleep, a `waitForEvent`, or a child run. | | `input` | The step's recorded input. Present for a `step.run(id, input, fn)` that supplied one, and a synthesized descriptor for a structural step (a `sleep` records `{ sleepMs }`, a `waitForEvent` records `{ event, timeoutMs }`, a `runWorkflow` records `{ name, data, app, runner }`, etc.). Absent when the step recorded none. | | `output` | The step's result on success. For a **skipped** step this holds the skip reason passed to [`ctx.step.skip`](/reference/sdk/steps) (a string or JSON value); absent when the step was skipped with no reason. Absent while the step is running/waiting and on a failed step. | | `error` | On a failed step, the failure as `{ message, stack? }`. Absent otherwise. | | `ai` | The opaque `step.ai.*` journal block, present only on a step that made a model call. Duraton stores it verbatim and never parses it. | | `runAfter` | A future timestamp the step is scheduled to resume/retry at. On a **failed** step scheduled to retry it is the next-attempt time (mirrored as `nextAttemptAt`); on a **waiting** step it is the sleep wake time or `waitForEvent` timeout. Absent when neither applies. | | `willRetry` | `true` only on a failed step that has a retry scheduled. Absent (falsey) on a terminal failure - so a transient retry renders distinctly from a hard failure on reload, exactly as on the [live stream](#live-run-stream). | | `nextAttemptAt` | The next attempt's time, set together with `willRetry`. Absent on a terminal failure. | | `eventName` | The event a `waitForEvent` step is parked on. Absent on other step kinds. | | `childRunId` | The run a `runWorkflow` step spawned, so the call is followable forward the way the child's `parentRunId` follows it back. Absent on a step that spawned none. | | `startedAt` / `endedAt` | When the step (attempt) started and finished (RFC3339). `endedAt` is absent while it is still running or waiting. | | `durationMs` | The attempt's wall-clock duration in whole milliseconds, once it has finished. A skipped step records `0`. | **Every attempt is returned, not just the latest.** Each `(step, attempt)` is its own row, ordered by `index` then `attempt`. A step that failed and retried appears as a `failed` row (with `willRetry` / `nextAttemptAt`) followed by the row for the next attempt - the whole retry history is visible, so you never have to reconstruct it. To render one row per step, keep the highest-`attempt` row per `index`. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const steps = await duraton.runs.steps(""); // Step[] ``` ```sh theme={null} curl "$DURATON_URL/runs//steps" ``` ## Run logs `GET /runs/{id}/logs` returns the structured logs a run emitted via [`ctx.log`](/core/logging), oldest first. Each entry is one captured line: ```json theme={null} [ { "seq": 1, "ts": "2026-06-22T10:00:00Z", "level": "info", "message": "ticket received", "fields": { "ticketId": "T-421" }, "scope": "@root", "attempt": 1 }, { "seq": 2, "ts": "2026-06-22T10:00:00Z", "level": "info", "message": "triaging ticket", "fields": { "priority": "high" }, "scope": "triage", "attempt": 1 } ] ``` | Field | Meaning | | --------- | ---------------------------------------------------------------------------------- | | `seq` | Per-run monotonic sequence; pass the last one you saw as `?from=` to page forward. | | `ts` | When Duraton persisted the line (RFC3339). | | `level` | `debug`, `info`, `warn`, or `error`. | | `message` | The log message. | | `fields` | Structured fields, with sensitive keys redacted. Absent when none were logged. | | `scope` | The step the log came from, or `@root` for a handler-level log. | | `attempt` | The attempt the line was recorded under. | | Param | Meaning | Default | | ------- | ------------------------------------------------------- | -------------------- | | `from` | Exclusive lower bound on `seq`; returns lines after it. | `0` (from the start) | | `limit` | Page size, `1`-`1000`. | `100` | ```sh theme={null} curl "$DURATON_URL/runs//logs?from=0&limit=100" ``` Logs are captured once and persisted durably even under replay: a handler-level log re-runs on every pass but is recorded once, while a retried step's logs stay distinct per attempt. See the [logging guide](/core/logging) for how `ctx.log` works. ## Live run stream `GET /runs/{id}/stream` is a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) stream of a run's **timeline**: every status transition and `ctx.log` line, in order, as they happen. It replays the timeline from the start on connect, then tails new rows live, and ends on its own once the run is terminal. See the [realtime guide](/core/realtime) for how it works. Each event is one timeline frame. The SSE `event:` line carries the frame `kind`; the `data:` object repeats it so a non-browser client can discriminate without reading the line: ``` event: step_status data: {"kind":"step_status","seq":7,"ts":"2026-06-22T10:00:01Z","runId":"01H...","name":"triage","status":"succeeded","attempt":1} event: log data: {"kind":"log","seq":8,"ts":"2026-06-22T10:00:01Z","runId":"01H...","level":"info","message":"triaged","scope":"triage","attempt":1} event: run_status data: {"kind":"run_status","seq":9,"ts":"2026-06-22T10:00:02Z","runId":"01H...","status":"succeeded"} ``` Every frame carries `kind`, `seq`, `ts`, and `runId`; the rest depends on `kind`: | `kind` | Extra fields | | ------------- | ------------------------------------------------------------------------------- | | `run_status` | `status`, `currentStepName?`, `attempt?` | | `step_status` | `name`, `status`, `attempt`, `index?`, `error?`, `willRetry?`, `nextAttemptAt?` | | `log` | `level`, `message`, `fields?`, `scope`, `attempt` | On a `step_status` frame with `status: "failed"`, `willRetry` is `true` and `nextAttemptAt` is the ISO time of the next attempt when the step is scheduled to retry; both are absent on a terminal failure. The same two fields appear on each step from `GET /runs/{id}/steps`, so a transient retry renders distinctly from a hard failure on reload as well as on the live stream. | Param | Meaning | Default | | ------ | ------------------------------------------------------------------------- | -------------------- | | `from` | Exclusive lower bound on `seq`; resumes a stream losslessly after a drop. | `0` (from the start) | ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); for await (const frame of duraton.runs.watch("")) { if (frame.kind === "log") console.log(frame.level, frame.message); else console.log(frame.kind, frame.status); } ``` ```sh theme={null} curl -N "$DURATON_URL/runs//stream" ``` Frames are read from the run's stored timeline, not from an in-memory bus, and every frame carries a per-run monotonic `seq`. That is what makes a drop recoverable: remember the last `seq` you processed and reconnect with `?from=`, and the stream replays every frame after it before tailing again. ## Project-wide run stream `GET /runs/stream` is the project-level counterpart: an SSE stream of **`run_status` frames across every run in the project**, for keeping a runs list or overview live without polling. It carries only run status transitions - not steps or logs - to stay bounded. ``` event: run_status data: {"kind":"run_status","seq":12,"ts":"2026-06-22T10:00:03Z","runId":"01H...","status":"succeeded"} ``` Unlike the per-run stream there is **no `seq` cursor** here: a per-run `seq` is not ordered across runs, so this stream tails by timestamp and is **best-effort**. Treat each frame as a signal to refetch the affected run or the list, not as a lossless log - a missed frame is self-correcting, since the next transition triggers another refetch that also reflects the run you missed. The console's runs list and stats are built on exactly this: they refetch on activity instead of on a timer. ```sh theme={null} curl -N "$DURATON_URL/runs/stream" ``` ## Run stats `GET /runs/stats` summarizes the run set for a filter. It accepts `app`, `workflow`, `replayOf`, `parentRunId`, and `since` (the same meaning as above): ```sh theme={null} curl "$DURATON_URL/runs/stats?app=demo&since=2026-06-01T00:00:00Z" # { "total": 1575, "active": 1, "queued": 0, "running": 1, "succeeded": 1371, "failed": 202, "successRate": 87, "p50Ms": 740, "p95Ms": 3120 } ``` | Field | Meaning | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `total` | All runs matching the filter. | | `active` | Non-terminal runs: `queued` + `running` + `waiting`. | | `queued` | Runs admitted and awaiting a worker. Always present, `0` when none. | | `running` | Runs actively executing a step. Always present, `0` when none. | | `succeeded` | Runs that finished successfully. | | `failed` | Runs that finished in failure. | | `successRate` | `succeeded / (succeeded + failed)`, rounded to a whole-number percent (`100` when nothing has finished). | | `p50Ms` / `p95Ms` | Median and 95th-percentile run duration (whole milliseconds) over the filter's finished runs. Absent when none have finished. See [percentile computation](/ai/observability#latency-percentiles). | ## Run time series `GET /runs/timeseries` buckets the same run set over time - the endpoint behind the console's run charts. It accepts `app`, `workflow`, and `since`, plus `bucket` (the bucket width in seconds, default `3600`): ```sh theme={null} curl "$DURATON_URL/runs/timeseries?since=2026-07-01T00:00:00Z&bucket=3600" ``` ```json theme={null} { "bucketSeconds": 3600, "buckets": [ { "ts": "2026-07-01T10:00:00Z", "counts": { "succeeded": 41, "failed": 2 }, "total": 43, "avgMs": 812, "maxMs": 4310, "p50Ms": 640, "p95Ms": 3980 } ] } ``` | Field | Meaning | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `bucketSeconds` | The bucket width the series was built with. | | `buckets[].ts` | The bucket's start (RFC3339, UTC). | | `buckets[].counts` | Runs in the bucket keyed by status; a status with no runs is absent. | | `buckets[].total` | All runs started in the bucket. | | `buckets[].avgMs` / `maxMs` | Mean and longest run duration in the bucket, over its finished runs only. Absent when no run in it has finished. | | `buckets[].p50Ms` / `p95Ms` | Median and 95th-percentile run duration in the bucket, over its finished runs only. Absent when no run in it has finished. See [percentile computation](/ai/observability#latency-percentiles). | Runs are bucketed by when they started, oldest bucket first, and a `status` filter does not apply here * the point of the series is the status split within each bucket. A query is capped at 5000 (bucket, status) rows, keeping the most recent buckets; widen `bucket` to cover a longer window. `parentRunId` is deliberately not accepted here, though `GET /runs` and `GET /runs/stats` both take it: one run's handful of children is a set to list or count, not a rate to chart over time. ## AI spend & sessions Two read endpoints roll up Duraton's AI metering for [observability](/ai/observability). Both meter tokens and report **cost only where a call supplied one** - Duraton holds no price list, so `cost` fields stay absent rather than defaulting to `0`. `GET /ai/spend` returns window totals plus by-hour, by-model, and by-workflow breakdowns. It accepts `app`, `workflow`, and `since` (as above), plus `bucket` (the by-hour width in seconds, default `3600`): ```json theme={null} { "tokens": 2904, "calls": 9, "bucketSeconds": 3600, "hourly": [{ "ts": "2026-07-01T10:00:00Z", "tokens": 2904, "calls": 9 }], "byModel": [ { "model": "demo-agent-1", "tokens": 2274, "calls": 3, "avgLatencyMs": 142 }, { "model": "demo-generate-1", "tokens": 392, "calls": 2, "avgLatencyMs": 96 }, { "model": "demo-chat-1", "tokens": 238, "calls": 1 }, { "model": "demo-embed-1", "tokens": 0, "calls": 3 } ], "byWorkflow": [{ "workflow": "ai.triage", "app": "ai-demo", "tokens": 2904, "runs": 1 }], "avgLatencyMs": 118, "cacheHits": 0, "cacheEligible": 0 } ``` `avgLatencyMs` is the mean call latency across calls that reported one - absent when none did, never `0`-filled - given for the window total and, in `byModel`, broken out per model (`demo-chat-1` and `demo-embed-1` above report no latency, so the field is simply absent on those rows). `cacheHits` over `cacheEligible` is the [inference-cache](/reference/sdk/ai-steps#inference-cache) hit rate, counting only calls that used the cache; both stay `0` when no call in the window engaged it. `GET /sessions` groups runs into conversation **sessions**, most recent first. Set an event's `session` to a stable conversation id (the OpenTelemetry `gen_ai.conversation.id`) to thread its runs together; omit it and each run is its own session. It accepts `app`, `since`, and `limit` (default `100`, max `500`): ```json theme={null} [ { "session": "conv_18f", "runCount": 3, "statusCounts": { "succeeded": 3 }, "aiTokens": 2512, "firstStartedAt": "2026-07-01T10:00:00Z", "lastStartedAt": "2026-07-01T10:04:00Z" } ] ``` `aiTokens` and `aiCost` are absent when no run in the session made a model call / supplied a cost. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const spend = await duraton.ai.spend({ since: "2026-07-01T00:00:00Z" }); const sessions = await duraton.sessions.list({ app: "assistant" }); ``` ```sh theme={null} curl "$DURATON_URL/ai/spend?since=2026-07-01T00:00:00Z&bucket=3600" curl "$DURATON_URL/sessions?app=assistant&limit=100" ``` # Webhooks API Source: https://docs.duraton.ai/reference/api/webhooks Prove a delivery happened and fix it when it did not: read the inbound and outbound delivery logs, manage source and endpoint configs, redeliver, or replay. The webhooks API backs the console's Webhooks view. It exposes both the outbound delivery log (with per-attempt history) and the inbound **source** delivery log, lets you **redeliver** an outbound delivery or **replay** an inbound one, and provides full CRUD for the inbound **source** and outbound **endpoint** configs. Signing secrets are **shown once** when a config is created or rotated and are never returned by any read. The config-write routes (`POST`/`PATCH`/`DELETE` on endpoints and sources) and the redeliver and replay routes require a **secret** API key. Duraton seals every signing secret at rest for you. | Method + path | Purpose | | --------------------------------------------- | ----------------------------------------------------------------------------------- | | `GET /webhook-deliveries` | A page of outbound deliveries, newest first, plus an `X-Next-Cursor` header. | | `GET /webhook-deliveries/{id}` | One delivery with its full `attempts` log. | | `POST /webhook-deliveries/{id}/redeliver` | Re-queue a delivery for an immediate fresh attempt. Returns `204`. | | `GET /webhook-source-deliveries` | A page of inbound source deliveries, newest first, plus an `X-Next-Cursor` header. | | `GET /webhook-source-deliveries/{id}` | One inbound delivery with its request headers, stored body, and `attempts` log. | | `POST /webhook-source-deliveries/{id}/replay` | Re-ingest a verified delivery's stored body. Returns `200` with the replay outcome. | | `GET /webhook-endpoints` | The outbound subscription configs (no secrets). | | `GET /webhook-endpoints/stats` | Per-endpoint delivery health over a window. | | `GET /webhook-endpoints/{id}` | One outbound endpoint config (no secret). | | `POST /webhook-endpoints` | Create an outbound endpoint; returns `201` with the signing secret once. | | `PATCH /webhook-endpoints/{id}` | Edit an endpoint; optionally rotate its secret. Returns `200`. | | `DELETE /webhook-endpoints/{id}` | Delete an endpoint. Returns `204`. | | `GET /webhook-sources` | The inbound source configs (no secrets). | | `GET /webhook-sources/{id}` | One inbound source config (no secret). | | `POST /webhook-sources` | Create an inbound source; returns `201` with the signing secret once. | | `PATCH /webhook-sources/{id}` | Edit a source; optionally rotate its secret. Returns `200`. | | `DELETE /webhook-sources/{id}` | Delete a source. Returns `204`. | See the [webhooks guide](/integrations/webhooks) for what produces these rows. Endpoints and sources can also be managed from the console's Webhooks view; both paths write the same rows. ## Listing deliveries `GET /webhook-deliveries` accepts these query parameters: | Param | Meaning | Default | | -------- | ----------------------------------------------------------------------------------------- | -------- | | `status` | One delivery status: `pending`, `delivering`, `succeeded`, `failed`, `exhausted`, `dead`. | all | | `app` | Restrict to deliveries for one app's runs. | all apps | | `limit` | Page size, `1`-`200`. | `30` | | `cursor` | Opaque keyset cursor from a previous page's `X-Next-Cursor`. | none | Paging is **keyset** over `(createdAt, id)` - the same model as [`GET /runs`](/reference/api/runs#keyset-pagination): the response carries up to `limit` deliveries plus an `X-Next-Cursor` header when more exist; the header is absent on the last page. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const page = await duraton.webhooks.deliveries.list({ app: "shop", status: "exhausted", limit: 20 }); page.deliveries; // WebhookDelivery[] page.nextCursor; // pass back as { cursor } for the next page ``` ```sh theme={null} curl "$DURATON_URL/webhook-deliveries?app=shop&status=exhausted&limit=20" ``` Each delivery has: | Field | Meaning | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | `id` | Delivery id. | | `app` | The app whose run produced the delivery. | | `endpointId` | The subscribed endpoint, or absent for a `ctx.webhook.send`. | | `url` | The destination Duraton POSTs to. | | `eventKind` | `run.succeeded`, `run.failed`, `run.cancelled`, `step.succeeded`, `step.failed`, `step.skipped`, or `custom`. | | `sourceRunId` | The run whose lifecycle produced it (absent for a custom send). | | `payload` | The body Duraton sends. | | `status` | `pending`, `delivering`, `succeeded`, `failed` (awaiting retry), `exhausted` (retries spent), or `dead` (non-retryable response). | | `attemptCount` / `maxAttempts` | Attempts made / allowed. | | `lastStatusCode` | The latest attempt's HTTP status (absent until a code is recorded). | | `nextAttemptAt` | When Duraton next retries (while `failed`). | | `createdAt` / `updatedAt` | RFC3339 timestamps. | ## One delivery and its attempts `GET /webhook-deliveries/{id}` returns the delivery above plus an `attempts` array - the append-only log of every POST Duraton made, which is the per-attempt detail the console's delivery inspector shows. A wrong-project id reads back as `404`. ```json theme={null} { "id": "9f2b...", "app": "shop", "url": "https://hooks.example/sink", "eventKind": "run.failed", "status": "exhausted", "attemptCount": 5, "maxAttempts": 5, "attempts": [ { "id": "a1", "attempt": 1, "outcome": "http_error", "statusCode": 500, "responseSnippet": "boom", "durationMs": 42, "requestHeaders": { "X-Duraton-Event": "run.failed", "X-Duraton-Signature": "t=1750000000&s=..." }, "responseHeaders": { "Content-Type": "text/plain" }, "createdAt": "2026-06-25T10:00:00Z" }, { "id": "a2", "attempt": 2, "outcome": "timeout", "durationMs": 10000, "createdAt": "2026-06-25T10:00:11Z" } ] } ``` | Attempt field | Meaning | | ----------------- | ----------------------------------------------------------------------- | | `attempt` | 1-based attempt number. | | `outcome` | `succeeded`, `http_error`, `timeout`, `connection_error`, or `skipped`. | | `statusCode` | The HTTP status, when the partner responded. | | `responseSnippet` | A bounded prefix of the response body, for debugging. | | `error` | The transport error, when there was no response. | | `durationMs` | How long the attempt took. | | `requestHeaders` | The exact signed header set sent (identifiers + signature). | | `responseHeaders` | The headers the endpoint returned, absent when there was no response. | ```ts theme={null} const detail = await duraton.webhooks.deliveries.get(""); detail.attempts; // WebhookDeliveryAttempt[] ``` ```sh theme={null} curl "$DURATON_URL/webhook-deliveries/" ``` ## Redelivering a delivery `POST /webhook-deliveries/{id}/redeliver` re-queues a delivery for an immediate fresh attempt and returns `204`. It keeps the existing attempt log and grants a new retry budget, so Duraton signs and POSTs it again. Use it to re-send a delivery that `exhausted` its retries, `dead`-lettered on a non-retryable response, or already `succeeded` (a manual re-send). A delivery that is currently in flight (`delivering`) cannot be redelivered - the call returns `409` so a manual redeliver never races an in-flight attempt. A missing or wrong-project id returns `404`. ```sh theme={null} curl -X POST "$DURATON_URL/webhook-deliveries//redeliver" ``` ## The inbound delivery log Every POST to a [source's receive URL](/integrations/webhooks#inbound-sources) is recorded as an inbound **source delivery**, alongside its admission outcome - the received-side counterpart to the outbound delivery log above. A verified delivery can be **replayed** to re-ingest its stored body. `GET /webhook-source-deliveries` accepts these query parameters, all optional; omit them for a workspace-wide listing: | Param | Meaning | Default | | -------- | ------------------------------------------------------------------------------------------------------ | ----------- | | `source` | Restrict to one source's deliveries. | all sources | | `status` | One admission outcome: `ingested`, `deduped`, `unauthorized`, `invalid`, `too_large`, `misconfigured`. | all | | `limit` | Page size, `1`-`200`. | `30` | | `cursor` | Opaque keyset cursor from a previous page's `X-Next-Cursor`. | none | Paging is **keyset**, the same model as `GET /webhook-deliveries`: the response carries up to `limit` deliveries plus an `X-Next-Cursor` header when more exist. A `status` outside the accepted set (`ingested`, `deduped`, `unauthorized`, `invalid`, `too_large`, `misconfigured`) returns `400` naming the accepted values, rather than an empty page - so a typo is a loud error, not a silently empty result. ```ts theme={null} const page = await duraton.webhooks.sourceDeliveries.list({ source: "", status: "unauthorized", limit: 20 }); page.deliveries; // WebhookSourceDelivery[] page.nextCursor; // pass back as { cursor } for the next page ``` ```sh theme={null} curl "$DURATON_URL/webhook-source-deliveries?source=&status=unauthorized&limit=20" ``` Each delivery's `status` is the **original** admission outcome of that POST, frozen at ingest - a later replay does not rewrite it (per-replay outcomes live in [`attempts[]`](#one-inbound-delivery-and-its-attempts)): | `status` | Meaning | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `ingested` | Verified and emitted an event (`eventId`). A run may have started - see `runId`. | | `deduped` | Verified, but dropped by the source's `dedupeKey`, so no event was produced. | | `unauthorized` | The signature did not verify. `failureReason` names the failed check: `missing_signature`, `malformed_signature`, `timestamp_out_of_tolerance`, or `signature_mismatch`. | | `invalid` | Verified, but the body was not JSON. | | `too_large` | The body was over the size limit. | | `misconfigured` | The source's stored secret could not be read, so the POST could not be verified. | ### One inbound delivery and its attempts `GET /webhook-source-deliveries/{id}` returns the delivery with its request headers, the stored body (present only for a delivery that verified), and an append-only `attempts` log. Attempt 1 is the initial ingest (`trigger: initial`); each manual replay appends an attempt (`trigger: replay`) recording the `actor` who triggered it. A wrong-project id reads back as `404`. ```json theme={null} { "id": "d7a1...", "source": "s_9f3c", "status": "ingested", "eventId": "evt_5d1a", "runId": "r_82b4", "requestHeaders": { "content-type": "application/json", "x-duraton-signature": "t=1750000000&s=..." }, "body": "{ \"amount\": 4200, \"currency\": \"usd\" }", "attempts": [ { "id": "a1", "attempt": 1, "trigger": "initial", "status": "ingested", "eventId": "evt_5d1a", "runId": "r_82b4", "createdAt": "2026-06-25T10:00:00Z" }, { "id": "a2", "attempt": 2, "trigger": "replay", "status": "deduped", "actor": "ops-key", "createdAt": "2026-06-25T11:30:00Z" } ] } ``` The row and each attempt carry `eventId` (the event the admission emitted) and `runId` (a run it woke), so you can pivot from a delivery to its event to a run. Both are **omitted** when the admission produced neither - a deduped or rejected post, or an ingest that matched no workflow - as on the replayed attempt above. The delivery's top-level `status` is the **original** admission outcome and is frozen: a replay never rewrites it, so `?status=ingested` still returns a delivery after it has been replayed. Each replay's own outcome lives only in its `attempts[]` row - the attempt above ingested first, then deduped on replay, while the delivery stays `ingested`. ```ts theme={null} const detail = await duraton.webhooks.sourceDeliveries.get(""); for (const a of detail.attempts) console.log(a.trigger, a.status, a.actor); ``` ```sh theme={null} curl "$DURATON_URL/webhook-source-deliveries/" ``` ### Replaying an inbound delivery `POST /webhook-source-deliveries/{id}/replay` re-ingests the stored body and returns `200` with the replay outcome - what re-ingesting the body produced - as `{ deliveryId, status, eventId?, runId? }`. It requires a **full-access** key. Only a delivery that originally verified (`ingested` or `deduped`) is replayable - a rejected delivery stored no verified body. The replay outcome is recorded as a new `attempts[]` row on the delivery; the delivery's top-level `status` stays the original admission outcome. Replay does **not** re-check the signature (the delivery was verified when it arrived, and its signed timestamp would now be far outside the tolerance window). It re-runs the source's *current* `dedupeKey` and event mapping over the stored body, so within a dedupe window a replay dedupes exactly as a real provider redelivery would. See the [guide](/integrations/webhooks#the-inbound-delivery-log) for the replay semantics, including that a replay **re-triggers downstream workflow effects**. | Result field | Meaning | | ------------ | --------------------------------------------------------------------------------------------------------------- | | `deliveryId` | The replayed delivery's id. | | `status` | The re-ingest outcome: `ingested` (a new event was emitted) or `deduped` (the source's `dedupeKey` dropped it). | | `eventId` | The event the replay emitted - present only when `status` is `ingested`. | | `runId` | The run the replay woke - present only when `status` is `ingested`. | ```ts theme={null} const result = await duraton.webhooks.sourceDeliveries.replay(""); result.status; // "ingested" | "deduped" result.eventId; // set only when it ingested result.runId; // set only when it ingested ``` ```sh theme={null} curl -X POST "$DURATON_URL/webhook-source-deliveries//replay" \ -H 'authorization: Bearer ' # 200 # { "deliveryId": "d7a1...", "status": "ingested", "eventId": "evt_7f10", "runId": "r_9c02" } ``` The inbound source-delivery routes are exposed by the TypeScript SDK's `duraton.webhooks.sourceDeliveries` surface (`list`, `listAll`, `get`, `replay`). ## Managing endpoints and sources `GET /webhook-endpoints` and `GET /webhook-sources` return the outbound subscriptions and inbound sources for the project. Both **omit the signing secret entirely** - it is sealed at rest and never leaves Duraton in a read. ```ts theme={null} const endpoints = await duraton.webhooks.endpoints.list(); // WebhookEndpoint[] (name?, url, scheme, eventKinds, enabled) const sources = await duraton.webhooks.sources.list(); // WebhookSource[] (name?, token, receiveUrl?, eventName, scheme, enabled) ``` ### Subscribable event kinds An endpoint's `eventKinds` is the set of lifecycle kinds it subscribes to. Delivery is **subscription-gated**: an endpoint only receives a kind it explicitly subscribed to, so a payload never arrives for a kind you did not ask for. Every kind fires on a **terminal** transition - a run reaching a final state, or a step settling for the last time. | Kind | Fires when | | ---------------- | ------------------------------------------------------------------------------- | | `run.succeeded` | A run reached its terminal succeeded state. | | `run.failed` | A run reached its terminal failed state. | | `run.cancelled` | A run was cancelled. | | `step.succeeded` | A step settled successfully - per-step **progress**, not just per-step failure. | | `step.failed` | A step settled with a terminal failure. | | `step.skipped` | A step was skipped rather than executed. | The `step.*` kinds are **per-step**: subscribe to them to track a run's progress step by step instead of waiting for the whole run to finish. They fire only on a step's terminal transition (not on intermediate retries). `custom` is not subscribable - it is produced by a workflow's `ctx.webhook.send`, not by subscribing an endpoint. ### Creating `POST /webhook-endpoints` creates an outbound subscription; `POST /webhook-sources` creates an inbound source. The response includes the freshly generated `secret` **once** - store it now; it is never returned again. Supplying your own `secret` adopts it instead of generating one. ```sh theme={null} # outbound endpoint: deliver run lifecycle events to a URL curl -X POST "$DURATON_URL/webhook-endpoints" \ -H 'authorization: Bearer ' -H 'content-type: application/json' \ -d '{ "name": "Acme prod", "url": "https://hooks.example/sink", "eventKinds": ["run.failed", "run.succeeded"] }' # inbound source: map a verified inbound POST onto a Duraton event curl -X POST "$DURATON_URL/webhook-sources" \ -H 'authorization: Bearer ' -H 'content-type: application/json' \ -d '{ "name": "Stripe", "eventName": "stripe.charge" }' ``` | Endpoint body | Meaning | | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | Optional label; the console falls back to the URL when absent. | | `app` | Optional - restrict deliveries to one app; absent means all apps. | | `url` | Required destination. | | `eventKinds` | One or more of the [subscribable event kinds](#subscribable-event-kinds): `run.succeeded`, `run.failed`, `run.cancelled`, `step.succeeded`, `step.failed`, `step.skipped`. | | `secret` | Optional - adopt a known secret instead of generating one. | A duplicate endpoint `url` for the same app, or a duplicate endpoint `name` within the project, returns `409`. | Source body | Meaning | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `name` | Optional label; the console shows a short form of the receive URL when absent. Unique within the project. | | `app` | Optional - the app the produced event belongs to. | | `eventName` | Required - the Duraton event a verified post is mapped to. | | `targetApp` | Optional - target a specific app for the produced event. | | `dedupeKey` | Optional - a dotted path into the inbound payload; a repeat whose value at it was already seen within the dedupe window is accepted but produces no event. A delivery missing the field is not deduplicated. | | `secret` | Optional - adopt a known signing secret instead of generating one. | The receive URL is **not** an input: Duraton always issues a 128-bit random, unguessable token for it (immutable after create) and returns the full URL as `receiveUrl` on the created source. A duplicate source `name` within the project returns `409`. ### Editing, rotating, deleting `PATCH` accepts a partial body - omitted fields are left unchanged. Set `rotateSecret: true` to issue a new signing secret; the response then carries the new `secret` once (it is absent on an edit that did not rotate). `DELETE` removes the config and returns `204`. A wrong-project id returns `404`. ```sh theme={null} # disable an endpoint and rotate its secret curl -X PATCH "$DURATON_URL/webhook-endpoints/" \ -H 'authorization: Bearer ' -H 'content-type: application/json' \ -d '{ "enabled": false, "rotateSecret": true }' curl -X DELETE "$DURATON_URL/webhook-endpoints/" -H 'authorization: Bearer ' ``` ## Endpoint delivery stats `GET /webhook-endpoints/stats` rolls up the delivery log per endpoint so the console can show delivery health without a stored health field. An optional `since` (RFC3339) bounds the window; absent, it defaults to the last 30 days. | Field | Meaning | | -------------- | ---------------------------------------------------------- | | `endpointId` | The endpoint the row aggregates. | | `delivered` | Total deliveries in the window (including in-flight). | | `succeeded` | Deliveries that settled successfully. | | `failed` | Deliveries that terminally failed (`exhausted` or `dead`). | | `lastDelivery` | The most recent delivery's timestamp. | ```sh theme={null} curl "$DURATON_URL/webhook-endpoints/stats?since=2026-06-01T00:00:00Z" ``` # Workflows API Source: https://docs.duraton.ai/reference/api/workflows See what your runners actually registered - triggers, schedules, retry policy, flow control, and the step manifest - and start a run by hand. `GET /workflows` returns the workflow **definitions** currently registered in the project - the shape a runner declared when it [connected](/reference/sdk/connect). It is the read model behind the console's Workflows view. Definitions are registered by your runners; there is no write endpoint for them. `POST /workflows/{app}/{name}/trigger` starts one off-schedule run of a registered workflow by identity - see [trigger a run manually](#trigger-a-run-manually) below. | Method + path | Returns | | -------------------------------------- | ------------------------------------------------ | | `GET /workflows` | Every workflow registered in the project. | | `POST /workflows/{app}/{name}/trigger` | Starts one run of that workflow. `202 Accepted`. | `GET /workflows` takes no query parameters - it returns the whole set. Filter client-side by `app` when you only want one app's workflows. ## Response An array of workflow definitions: ```json theme={null} [ { "name": "fulfillment", "app": "support-app", "maxAttempts": 3, "backoff": "exponential", "triggers": [ { "event": "ticket.created" }, { "event": "ticket.updated", "if": "event.data.priority == 'high'" } ], "scheduled": false, "flowControl": { "concurrency": { "limit": 5, "key": "customerId" }, "idempotency": { "key": "ticketId", "periodMs": 86400000 } }, "steps": [ { "name": "validate", "description": "Validate the ticket" }, { "name": "triage" }, { "name": "audit", "hidden": true } ], "registeredAt": "2026-06-01T09:00:00Z", "updatedAt": "2026-06-22T10:00:00Z" }, { "name": "nightly-report", "app": "support-app", "maxAttempts": 1, "backoff": "fixed", "scheduled": true, "schedules": [ { "cron": "0 2 * * *", "nextFireAt": "2026-06-23T02:00:00Z", "lastFiredAt": "2026-06-22T02:00:00Z", "isStale": false } ], "registeredAt": "2026-06-01T09:00:00Z", "updatedAt": "2026-06-01T09:00:00Z" } ] ``` | Field | Meaning | | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `name` | The registered workflow name - the dispatch key a run records and a `runWorkflow` targets. | | `app` | The app the workflow belongs to. | | `maxAttempts` | The workflow-wide [retry](/core/retries) attempt budget (`1` = no retry). | | `backoff` | The retry backoff shape: `fixed`, `linear`, or `exponential`. The finer bounds (`initialDelayMs` / `maxDelayMs`) are applied at runtime and are not re-emitted here. | | `triggers` | The workflow's [triggers](/core/triggers). Each entry sets exactly one of `event` (with an optional `if` CEL guard) or `cron`. Absent when the workflow declared none (it is then implicitly triggered by an event matching its `name`). | | `scheduled` | `true` when the workflow has at least one cron schedule. Always present. | | `schedules` | The resolved cron schedules, each `{ cron, nextFireAt, lastFiredAt?, isStale }` - see [detecting a dead schedule](/core/triggers#detecting-a-dead-schedule). Absent when the workflow has no cron trigger. | | `flowControl` | The configured [flow-control](/core/flow-control) policies, in the same millisecond-based shape they were registered with. A present sub-field is an active policy; the whole object is absent when none are set (see below). | | `steps` | The advisory [step manifest](#step-manifest) in declaration order. Absent when the workflow declared none. | | `registeredAt` | When the workflow was first registered (RFC3339). | | `updatedAt` | When its definition was last updated by a re-registration (RFC3339). | ### Flow control `flowControl` re-emits whatever [flow-control policies](/core/flow-control) the workflow registered, each as an optional sub-field. A field is present only when that policy is active; all durations are milliseconds: | Sub-field | Shape | | --------------- | ----------------------------------------------------------- | | `concurrency` | `{ limit, key? }` | | `throttle` | `{ limit, perMs, key? }` | | `rateLimit` | `{ limit, perMs, key? }` | | `debounce` | `{ periodMs, key? }` | | `batch` | `{ maxSize, timeoutMs, key? }` | | `priority` | `{ shiftMs }` | | `singleton` | `{ key?, mode? }` | | `idempotency` | `{ key?, periodMs? }` | | `cap` | `{ maxCost?, maxTokens? }` - a per-run AI spend ceiling | | `tokenThrottle` | `{ tokens, perMs, key? }` - a token-denominated AI throttle | ### Step manifest `steps` is the workflow's **advisory** manifest of declared steps, in declaration order. Each entry is: | Field | Meaning | | ------------- | ----------------------------------------------------------------------------------------------------- | | `name` | The declared step id - the id the run view diffs against the run's actually-executed steps. | | `description` | An optional human label for the step. Absent when none was declared. | | `hidden` | `true` to exclude a bookkeeping step from a customer-facing progress view. Absent (falsey) otherwise. | The manifest is rendering metadata only: it never gates execution, never fails a run for drift, and never matches emitted opcodes. When a run's executed steps disagree with the manifest, **discovery wins** - the run view shows what actually ran. A workflow that declares no manifest behaves exactly the same; the field is simply absent. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); const workflows = await duraton.workflows.list(); // WorkflowDef[] ``` ```sh theme={null} curl "$DURATON_URL/workflows" ``` The wire contract (`GET /workflows`) is stable, so any language can read it over REST today. ## Trigger a run manually `POST /workflows/{app}/{name}/trigger` starts one run of one workflow by identity, independent of its declared triggers. It works even for a workflow with only a `cron` trigger, which `POST /events` cannot reach - there is no event to send it. See the [manual trigger guide](/core/triggers#trigger-a-run-manually) for the cron-only case, how this differs from `POST /events`, and why flow control still applies. Every field is optional, and a request with no body at all is valid - that's the cron-only case: ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ url: process.env.DURATON_URL! }); // Just fire it. const res = await duraton.workflows.trigger("support-app", "nightly-report"); res.runId; // absent if a flow-control gate short-circuited it // With custom input, an event-name label, and tags. await duraton.workflows.trigger("support-app", "fulfillment", { input: { ticketId: "T-421" }, eventName: "ticket.created", tags: { team: "support" }, }); ``` ```sh theme={null} # Just fire it - no body needed. curl -X POST "$DURATON_URL/workflows/support-app/nightly-report/trigger" \ -H "Authorization: Bearer $DURATON_API_KEY" # With custom input, an event-name label, and tags. curl -X POST "$DURATON_URL/workflows/support-app/fulfillment/trigger" \ -H "Authorization: Bearer $DURATON_API_KEY" \ -d '{"input":{"ticketId":"T-421"},"eventName":"ticket.created","tags":{"team":"support"}}' ``` ### Request body | Field | Type | Meaning | | ----------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | | `input` | any JSON | Becomes `ctx.event.data` on the run. Defaults to `{}` (never `null`) when omitted. | | `eventName` | string | Overrides `ctx.event.name`. Defaults to the workflow's own name. **Label only** - it never fires an event, causes fan-out, or wakes a `waitForEvent` step. | | `dedupeId` | string | Drop a repeat of the same id (per workflow) within the dedupe window. Absent means no dedupe: a second call is a second run. | | `runner` | string | Pin the run to a specific runner id. Anycast (any capable runner) when absent. | | `tags` | object of string->string | Customer-defined key/value metadata attached to the run - same [limits](/reference/api/runs#run-tags) as event tags. | `eventName`, `dedupeId`, and `runner` are each bounded at 256 characters; a longer value or unparseable `input` returns `400`. ### Response `202 Accepted`. The run is created and enqueued, not executed - a caller that wants the finished result polls [`GET /runs/{id}`](/reference/api/runs) or uses [`runs.wait`](/reference/sdk/client#trigger-and-await). ```json theme={null} { "workflow": "nightly-report", "app": "support-app", "runId": "01HXYZ...", "eventName": "nightly-report" } ``` | Field | Meaning | | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `workflow` / `app` | The workflow the request addressed. | | `runId` | The run started. **Absent** when a flow-control gate short-circuited the request - not an error. | | `eventName` | The resolved `ctx.event.name` on the run: your `eventName` override, or the workflow's own name. | | `skipped` / `dropped` / `debounced` / `batched` / `deduped` | Flow-control outcomes - set (and `runId` absent) when the request was held back rather than run immediately. At most one is set. See [flow control](/core/flow-control). | A workflow not registered in the project (or registered in another project) returns `404`. No live runner capable of serving the workflow returns `502` - the run was never queued, so there's nothing to poll. # Reference Source: https://docs.duraton.ai/reference/index Every option, signature, endpoint, and bound: the TypeScript SDK, the REST API, and the protocol your runner speaks. This page is for the code path - signatures and endpoints, not explanations. To build an agent without code, start with [Build your first agent](/start/first-agent). The reference pages are tables and signatures, not explanations. For why a feature exists and when to reach for it, start from [Durable runs](/core), [AI](/ai), [Agent kit](/agent-kit), or [Integrations](/integrations); every reference page links back to its owner. ## TypeScript SDK `@duraton/sdk`: install, imports, and the scoped entries for client-only and AI-only code. `workflow`: name, triggers, retry, flow control, and the optional step and tool manifests. The `StepContext` and every `step.*` signature. Run a runner over an outbound WebSocket - no inbound URL. `createClient`: events, runs, approvals, workflows, and errors. `step.ai.generate`, `wrap`, `embed`, `loop`, providers, cost, and the cache store. ## REST API Base URL, authentication, conventions, and the resource map. List, filter, paginate, stream, and summarise runs; read steps, logs, and AI spend. Send events, read the event log, and [trigger a workflow](/reference/api/workflows) by hand. Cancel, pause, resume, replay, retry from a step, and the control-action audit log. Decide parked runs. Deliveries and endpoints, [apps and runners](/reference/api/runners), [rate limits and defaults](/reference/api/limits). ## Protocol How Duraton drives a runner: the endpoints, routing, and status codes. Status codes, the `409` conflict table, and how the SDK surfaces them. # AI steps Source: https://docs.duraton.ai/reference/sdk/ai-steps Make every model call run once: the step.ai reference for generate, wrap, embed, and the durable agent loop, with providers, cost, and cache options. `step.ai` makes a model call a durable step. Like [`step.run`](/reference/sdk/steps#step), each call takes a stable `id`, records its result under that id, and returns the saved result on replay instead of calling the model again. So a retry after a crash never re-spends on work that already completed. New to AI steps? The [AI quickstart](/start/ai-quickstart) walks you from a first `generate` call to spend landing in the console. Duraton stores the AI metadata (model, token counts, latency) as an opaque journal block. It never parses it and never stores your prompt, the response text, or your API key - those stay in your runner. ```ts theme={null} const { output } = await ctx.step.ai.generate("classify", { model: "claude-opus-4-8", prompt: `Classify this ticket: ${subject}`, output: triageSchema, }); ``` ## The step.ai API One model call as a durable step, with optional structured output + durable re-ask. Make any caller-supplied AI call durable - bring your own client. Batch embeddings with per-batch checkpointing. A durable agent loop: one durable step per turn and per tool call. One guardrail policy pass as a durable step, optionally guarding a call of your own. ## `step.ai.generate` Make one model call as a durable step. The built-in provider is Anthropic; the request is validated, sent, and the result memoized under `id`. ```ts theme={null} const result = await ctx.step.ai.generate("draft-reply", { model: "claude-opus-4-8", prompt: `Write a one-line apology for ticket ${ticketId}.`, }); // result.text, result.model, result.usage.inputTokens, result.usage.outputTokens ``` The model id to call, e.g. "claude-opus-4-8". The user prompt. An optional system prompt. Max output tokens. Sampling temperature. A JSON Schema the response must satisfy. Set it to get a parsed, validated result on output, with durable re-ask on failure. Max durable re-asks when output validation fails (default 1). Each re-ask is its own memoized step; 0 disables re-asking. A deeper check beyond "valid JSON": return an error string to reject the value (triggers a re-ask), or undefined to accept. The provider adapter to use. Defaults to "anthropic". Stream tokens as they arrive: each delta is journaled as an ai\_chunk timeline frame for a live, replayable view. The durable result is still the complete text. Falls back to a plain generate when the transport has no live channel. See Streaming below. Backup models tried in order after model when a call fails with a retryable error (429/5xx/timeout). The first to return wins. Each candidate is \{ model, provider? }. See Fallback chains below. Opt this call into the inference cache: an identical prior call is served without a provider call (zero spend). Engages only when temperature is explicitly \<= 0.2. true uses a 24h TTL; CacheOptions is \{ ttlMs?, seed? }. See Inference cache below. Ask the provider to bill this call's repeated prefix at its own cached rate - a different thing from cache above, which skips the provider call entirely. "prefix" caches the stable head, "conversation" also follows a transcript that grows call over call. The provider must declare the prompt-cache capability, or the call is refused rather than quietly billed in full. See Prompt caching below. Passed through per call and never stored; omit to fall back to the provider SDK env var (e.g. ANTHROPIC\_API\_KEY). `generate` returns a `StructuredResult`: The response text. The model that actually answered - may differ from the requested one (e.g. a server-side fallback). This is what the journal records. The provider that served the call. Four disjoint token axes: inputTokens counts the uncached input only, so inputTokens + cacheReadTokens + cacheCreationTokens is everything the provider processed. The two cache axes are filled in only when the provider reports them. See Prompt caching below. Why generation stopped. The parsed, validated value - present only when you passed output. ### Structured output and durable re-ask Pass `output` (a JSON Schema) to constrain the model and get a typed, validated value back on `result.output`. If the response fails to parse or validate, `generate` re-prompts with the validation error - each re-ask is its own memoized step, so the retry survives a crash and never repeats a committed attempt. Add `validate` for semantic rules the schema can't express. ```ts theme={null} const { output } = await ctx.step.ai.generate<{ category: string; priority: string }>("triage", { model: "claude-opus-4-8", prompt: `Triage: ${subject}`, output: { type: "object", properties: { category: { type: "string" }, priority: { type: "string" } }, required: ["category", "priority"], }, reask: 2, validate: (v) => (["low", "normal", "high"].includes((v as { priority: string }).priority) ? undefined : "priority out of range"), }); ``` The `apiKey` you pass is used for that one call and never written to the journal or the run store. Omit it to let the provider SDK read its conventional env var. ### Streaming Pass `stream: true` to feed the model's tokens to a live viewer as they arrive. Each delta is appended to the run's durable timeline as an `ai_chunk` frame, so a viewer sees the text build in real time and a late or reconnecting viewer replays it from token 0. The return value is unchanged - `result.text` is still the complete response, memoized on replay - so streaming affects only what a viewer sees while the step runs. ```ts theme={null} const result = await ctx.step.ai.generate("summarize-thread", { model: "claude-opus-4-8", prompt: `Summarize this thread:\n\n${thread}`, stream: true, }); // result.text is the full summary; deltas streamed live on the way there. ``` Live deltas travel over the [connect](/reference/sdk/connect) runner's socket. See the [Streaming](/ai/streaming) concept for the timeline frames, resumability, and the `useStream` React hook. ### Fallback chains Pass `fallback` - an ordered list of backup models - to keep a call resilient when a model is rate-limited or down. The primary `model` is tried first; if it fails with a **retryable** error (429, a 5xx, or a timeout), the call **advances** to the next candidate, and the first one to return wins. Its result is the step's durable output, so a caller never sees the failover. ```ts theme={null} const answer = await ctx.step.ai.generate("answer", { model: "claude-opus-4-8", prompt: question, fallback: [{ model: "claude-sonnet-4-6" }, { model: "claude-haiku-4-5" }], }); ``` Each candidate is `{ model, provider? }`; `provider` defaults to the call's provider, so a chain can span providers once you have more than one adapter configured. The step's journal records the outcome: The models tried, in order (the primary plus each fallback). The model that actually served the call. Why the chain advanced - the classified failures of the skipped models, e.g. "claude-opus-4-8: 429". Absent when the primary served. The console renders this as a chain pill on the AI step, and it rides the opaque journal so an agent reading the run over MCP sees the same `chain` / `used` / `reason`. Only 429, 5xx, and timeout advance the chain. A terminal 4xx (a bad request, an auth failure) fails the step immediately - another model won't fix a malformed request. An exhausted chain also fails the step, re-throwing the last error, so the workflow's own durable retry policy still applies. Fallback is per-call resilience, distinct from the [flow-control](/core/flow-control) spend controls (`cap` / `tokenThrottle`). ### Inference cache Set `cache` to reuse the result of an identical earlier call instead of paying for it again. On a **hit** the provider is never called, so the step commits with **zero spend** - the cache is the one control that *reduces* spend rather than capping it, and a cached call counts nothing against `cap` / `tokenThrottle`. Where step memoization already makes a **replay** free, the cache makes an identical call in a **different run** free too. ```ts theme={null} const answer = await ctx.step.ai.generate("answer", { model: "claude-opus-4-8", prompt: question, temperature: 0, // required: caching engages only for a deterministic call cache: true, // or { ttlMs, seed } }); ``` The key is an exact match over the runner's app, the model, the prompt, and every output-affecting parameter, so no entry ever crosses a project boundary and a different config never returns a stale answer. The step's journal records the outcome: Whether the call was served from the cache (true = the provider was not called). The entry key (metadata only - the cached completion is held runner-side and never reaches Duraton). How long ago the entry was stored, on a hit. The console renders this as a cache pill on the AI step, and it rides the opaque journal so an agent reading the run over MCP sees the same `hit` / `key` / `ageMs`. Caching is **exact-match** and engages only when `temperature` is explicitly set to `0.2` or lower - caching a sampled (high-temperature) answer would freeze one draw, and an *unset* temperature is treated as non-deterministic (a provider default is often 1.0). The default TTL is 24h, overridable per call with `{ ttlMs }`; `{ seed }` overrides the default project seed (the runner's app) to scope entries further. ## `step.ai.wrap` Makes a model call you already write yourself - through the OpenAI SDK, the Anthropic SDK, the Vercel AI SDK, or anything else - a durable step, with no other change to the call site. `wrap` returns your function's value unchanged; when it recognizes the response shape it enriches the journal with the model and token counts and records which library it wrapped. ```ts theme={null} import OpenAI from "openai"; const openai = new OpenAI(); const completion = await ctx.step.ai.wrap("classify", () => openai.chat.completions.create({ model: "gpt-4o", messages: [{ role: "user", content: subject }], }), ); ``` Recognized shapes: the Anthropic SDK, the OpenAI SDK, and the Vercel AI SDK. An unrecognized value still becomes a durable `wrap` step - you just get less metadata on the journal. ## `step.ai.embed` Turn a list of inputs into vectors, one durable batch at a time. Anthropic has no embeddings API, so you supply the embedding call (`embed`); Duraton owns the batching and per-batch checkpointing. If a batch fails, only that batch re-runs on retry - committed batches are not re-embedded. ```ts theme={null} const { vectors } = await ctx.step.ai.embed("embed-kb", { model: "voyage-3", inputs: ["duplicate charge policy", "annual plan refunds", "refund SLA"], embed: (batch) => voyage.embed(batch), batchSize: 2, }); ``` Names the embedding model - recorded on the journal. The inputs to embed. Your embedding call for one batch: inputs in, one vector per input out. Inputs per durable batch (default 100). Each batch checkpoints independently. `embed` returns `{ vectors }` - one vector per input, in input order. ## `step.ai.loop` A durable agent loop. Each turn is your own model call (bring-your-own, normalized to tool calls or a final answer); the loop executes the tools the turn requested and feeds the results into the next turn, until the model returns a final answer, `stop` fires, or `maxIterations` is reached. Every turn is a durable step, and so is every tool call it makes, so an agent that crashes mid-run resumes at the last committed turn. Writing `turn` yourself is the low-level path. To declare a model, instructions and tools and have the turn composed for you, use the [agent kit](/agent-kit) - it composes this loop rather than replacing it, so everything below still applies. ```ts theme={null} const agent = await ctx.step.ai.loop<{ resolution: string }>("agent", { prompt: `Resolve the ticket about: ${subject}`, maxIterations: 6, tools: { "search-kb": { handler: (input) => searchKb(input) }, "lookup-order": { workflow: "orders.lookup", app: "orders" }, }, turn: (ctx, iteration) => callModel(ctx.prompt, ctx.history, iteration), }); // agent.final, agent.iterations, agent.stopReason ``` The task the agent is working on; surfaced to turn via ctx.prompt. One model turn, a pure function of ctx - keep it deterministic for replay. Hard cap on turns; the loop halts before exceeding it. The tools the model may call, keyed by name. The gate for every tool that has not answered for itself. Resolved against each tool's own approval - see the agent kit for the order. Ceiling on the human decisions this loop may ask for. Past it the loop halts with stopReason "approval-budget". Omitted, there is none. Optional early stop after a completed turn; must be pure for replay. Your `turn` returns a `LoopTurn` - either tool calls to run, or a final answer: Tools to run this turn; each name must be a key in tools. The final answer. Returning this ends the loop with stopReason "final". The model that produced the turn - recorded on the journal. Input tokens for this turn. Output tokens for this turn. ### Which loop composed a turn Every turn's journal carries a `loopVersion` alongside the model and the token counts. The model says what answered; this says what asked: ```json theme={null} { "kind": "loop", "iteration": 0, "loopVersion": "typescript.1", "model": "claude-sonnet-5" } ``` It is written when the turn is composed and never rewritten, so replaying a year-old run shows the version that produced each turn rather than the version replaying it. Without it a replay quietly mixes old journal entries with new loop behaviour: the run replays, the numbers look reasonable, and the conclusion drawn from them is wrong. The value is prefixed with the language that implemented the loop, as in `typescript.1`, so an entry read later needs nothing but itself to be understood. Only turns carry it: a `generate` or an `embed` was not composed by the loop. Read it, do not pin on it. A bump means turns composed after it may differ from turns composed before, which is a reason to compare two runs carefully - not a reason to refuse the older one. A tool is either a **handler** (a local function) or a **workflow** (another Duraton workflow, called as a linked child run): A local function tool. The name of a workflow to run as this tool; its call becomes a linked child run - the tool's step carries childRunId and the spawned run carries parentRunId, parentStep and parentAttempt, so the call is followable both ways. The workflow tool's app; addressed like step.runWorkflow. Pin the workflow tool to a specific runner. Park the run on a human before this tool runs. See Gating a tool on a human below. Annotates that gate and decides whether it is raised at all. See Gating a tool on a human below. ### Gating a tool on a human A tool marked `requiresApproval` does not run until someone decides on it. The run parks in `needs_attention` holding no worker, exactly as [`step.approval`](/reference/sdk/steps#approval) does - it is the same gate, raised for you: ```ts theme={null} tools: { "issue-refund": { requiresApproval: true, handler: (input) => issueRefund(input) }, } ``` Three things follow, and each is a deliberate choice: | | | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **The decider's edits win** | The reviewer sees the input the model proposed and may change it. The tool runs with the decided input, not the proposed one. | | **A refusal is a tool result** | A denial comes back to the model as that tool's result (`{ approved: false, ... }`) rather than as a thrown error, so the agent can react to a no. The run does not fail. | | **The gate is durable** | Each approval is its own durable step, so resuming replays the loop without re-asking anyone. A denied call runs no tool and writes no tool step. | A tool's `approval` says more about that gate and can decide whether it is raised at all, and the loop's own `approval` is the default for every tool that has not answered for itself. Both are the same `ApprovalRule`: the environment a condition reads is on [Approvals](/ai/approvals#deciding-whether-to-gate-at-all), and the order the two levels resolve in is on the [agent kit](/agent-kit/approvals#which-rule-applies-to-a-tool). `maxIterations` bounds how many turns a loop may run; a per-run [spend cap](/ai/cost-controls) bounds how much it may spend. When a run reaches its cap the loop halts before its next turn's model call and the run fails with a `BudgetError` - the committed turns stay, and the halted turn is the loop's last (failed) iteration. `ctx.history` gives each turn the prior turns' `toolCalls` and `toolResults`, so your model call can see what it has already tried. `loop` returns: The final answer, if the loop reached one. How many turns ran. Why the loop ended. approval-budget means the loop reached maxApprovals; bail means a tool returned bail(); guardrail means a policy returned halt. Which named stop condition ended the loop, on stopReason "stopped". Absent when the opts.stop closure ended it, because a closure has no name to report. Which guardrail halted the loop, on stopReason "guardrail". The name only, never the verdict's reason. ## `step.ai.check` Ask a guardrail policy about a value, as a durable step. The verdict memoizes, so a replay reads what was decided instead of asking again. Supply `call` to guard one call with the policy: the check gates it, or with `parallel: true` races it and aborts its signal the moment the policy trips. ```ts theme={null} const gate = await ctx.step.ai.check("moderate", { guardrails: [createModerationGuardrail({ classify })], input: { placement: "pre-prompt", value: question }, output: (answer) => ({ placement: "post-model", value: answer }), call: () => askTheModel(question), }); ``` The policies to ask, in order; the first verdict that is not allow wins. A guardrail that does not declare the subject's placement is skipped. What to check before the guarded call: placement, value, and optionally schema and tool. Derives what to check from the call's result. Omitted, nothing is checked afterwards. The call the checks guard. Omitted, check is a plain policy evaluation. Race the input check against the call instead of gating the call on it. `check` returns `{ verdict, tripped, value?, result? }` - `value` is the replacement under a mask or rewrite verdict, and `result` is the guarded call's own return, withheld whenever a check refused it. A `halt` verdict fails the check's step non-retriably rather than returning; every other refusal comes back as `tripped`. See [Guardrails](/ai/guardrails) for the placements, the actions, and the adapters that ship. It records the input check, the guarded call and the output check as three separate durable steps. The verdict step journals `kind: "check"` with the placement, the action and the deciding guardrail - never the verdict's reason. ## Providers `step.ai.generate` resolves its `provider` name to an **`AIProvider`** adapter through a port, so you can supply your own instead of the built-in registry. Pass `resolveProvider` to [`connect`](/reference/sdk/connect) and every `generate` call in that runner goes through it - the call sites are unchanged. The same resolver is also reachable directly as `ctx.resolveProvider`, which is what `step.ai.loop` (and the [agent kit](/agent-kit)'s `agent()`) resolves its own model call through, since a loop's `turn` only ever sees `(ctx, iteration)` and has no `opts.provider`-shaped call site of its own to inject into. ```ts theme={null} import { connect } from "@duraton/sdk"; import { type AIProvider, createAnthropicProvider, getProvider } from "@duraton/sdk/ai"; const recording: AIProvider = { name: "anthropic", generate: (req) => fixtures[req.prompt] ?? getProvider("anthropic").generate(req), }; connect({ app: "support-app", workflows, resolveProvider: (name) => (name === "anthropic" ? recording : getProvider(name)), }); ``` | Export | Type | Description | | -------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PROVIDERS` | `readonly ["anthropic", "aisdk"]` | The closed set of provider names the SDK ships an adapter for. | | `ProviderName` | `"anthropic" \| "aisdk"` | The type derived from `PROVIDERS`; what `GenerateOptions.provider` accepts. | | `getProvider(name)` | `(name: ProviderName) => AIProvider` | The built-in registry: one adapter per name. The default `resolveProvider`. | | `createAnthropicProvider(opts?)` | `(opts?: { fetch?, baseURL? }) => AIProvider` | The Anthropic adapter. It loads `@anthropic-ai/sdk` lazily, so that package is an optional peer dependency you install only if you call Anthropic. | | `createAisdkProvider(opts?)` | `(opts?: { resolveModel? }) => AIProvider` | The Vercel AI SDK adapter - one adapter for every provider the AI SDK supports. It loads `ai` lazily, so that package is an optional peer dependency. See [Using any model provider](#using-any-model-provider). | | `ProviderResolver` | `(name: ProviderName) => AIProvider` | The `resolveProvider` option's type. | | `ToolDeclaration` | `{ name, description?, inputSchema }` | A tool the model may call, in Duraton's own vocabulary - no provider SDK type. | | `ToolCall` | `{ id, name, input }` | One tool the model asked to run. `input` is raw model output and is not validated against the declaration. | | `ToolResult` | `{ id, output }` | What one tool call returned, under the `id` of the call it answers. | | `TranscriptTurn` | `{ toolCalls, toolResults }` | One completed exchange: the tools the model asked to run, and what they returned. | | `PROVIDER_CAPABILITIES` | `readonly ["transcript", "structured-output", "prompt-cache"]` | The closed set of request fields an adapter opts into. | | `providerSupports(p, c)` | `(p: AIProvider, c: ProviderCapability) => boolean` | Whether an adapter honours a capability. A caller asks before sending the field. | | `PROMPT_CACHE_SCOPES` | `readonly ["prefix", "conversation"]` | The closed set of prompt-cache scopes. See [Prompt caching](#prompt-caching). | | `PromptCacheScope` | `"prefix" \| "conversation"` | The type derived from `PROMPT_CACHE_SCOPES`; what `GenerateOptions.promptCache` accepts. | An `AIProvider` implements `generate(req)` and, optionally, `stream(req, onDelta)` (an adapter without it falls back to `generate`, so `stream: true` still returns the right text) and `classifyError(err)` (which decides whether a failure is retryable, and so whether a [fallback chain](#fallback-chains) advances - an unclassified error is treated as terminal). It may also declare `capabilities` - see [Conversations](#conversations). ### Using any model provider The `aisdk` provider delegates to the [Vercel AI SDK](https://ai-sdk.dev), so one adapter reaches every provider the AI SDK supports - OpenAI, Google, Mistral, Bedrock, Groq and the rest - without Duraton shipping an adapter per vendor. Install `ai` alongside the provider package you want: ```bash theme={null} npm i ai @ai-sdk/openai ``` Point `resolveModel` at that package and wire it through `resolveProvider`: ```ts theme={null} import { openai } from "@ai-sdk/openai"; import { connect, createAisdkProvider, getProvider } from "@duraton/sdk"; await connect({ workflows, resolveProvider: (name) => name === "aisdk" ? createAisdkProvider({ resolveModel: (model) => openai(model) }) : getProvider(name), }); ``` Then ask for it per call: ```ts theme={null} const answer = await ctx.step.ai.generate("draft", { provider: "aisdk", model: "gpt-5.1", prompt: "Summarise this ticket.", }); ``` Without `resolveModel` the model string is passed to the AI SDK as-is, which resolves it through its global provider - the Vercel AI Gateway, requiring `AI_GATEWAY_API_KEY`. Supply `resolveModel` whenever you want to call a provider directly rather than route through the gateway. Two behaviours are worth knowing: * **`apiKey` on the call is ignored by this adapter.** The AI SDK carries credentials on the model, so the key belongs to whatever `resolveModel` returns (`openai({ apiKey })`). * **Duraton still owns the loop and the retries.** The adapter makes exactly one model call per step and disables the AI SDK's own retries, so a rate limit checkpoints and reschedules durably instead of blocking a worker. Tools are declared to the AI SDK without an executor, so every tool call comes back to [`step.ai.loop`](#step-ai-loop) and stays a durable, approvable step. The `anthropic` adapter is not deprecated by this. It imports no framework, which is what keeps `GenerateRequest` from drifting into any one vendor's types. ### Tool calling A `GenerateRequest` may carry `tools: ToolDeclaration[]`, and a `GenerateResult` may answer with `toolCalls: ToolCall[]`. If you write your own adapter, honour both halves: | Rule | Why | | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | | No `tools`, or an empty array, means send **no** tools field to your provider at all | A call that declares none must be indistinguishable from one made before tools existed | | Leave `toolCalls` **unset** when the model requested none - never an empty array | `toolCalls?.length` is the only check a caller makes | | A turn may carry text **and** tool calls; return both | Neither displaces the other | | Hand `input` back as the provider produced it | Validation is the caller's job, not the adapter's | Tell a tool turn from a final answer by whether `toolCalls` is present - never by reading `stopReason`, which stays your provider's own raw string. ### Conversations A multi-turn agent has a conversation, and `GenerateRequest.transcript` carries it: the exchanges that have already happened, oldest first, with `prompt` as the task that opened them. A provider's native tool-use protocol has a shape for this - Anthropic answers an assistant `tool_use` block with a user `tool_result` block - and the built-in adapter maps the transcript onto it. Ignoring the field would silently lose the history rather than lose a nicety, so an adapter has to say it reads it: ```ts theme={null} const recording: AIProvider = { name: "anthropic", capabilities: ["transcript"], generate: (req) => callMyProvider(req.prompt, req.transcript ?? []), }; ``` An adapter that declares nothing keeps working exactly as it did. The [agent kit](/agent-kit) checks with `providerSupports` and renders the turns into the prompt as text for anything that has not opted in, so no adapter is broken by the field existing. A turn carries the tool calls and their results, not the assistant's prose. `step.ai.loop` records exactly that much per turn, and a transcript built from anything else would stop being identical on replay. The API key rides each `GenerateRequest` and is never stored by the SDK, never journaled, and never sent to Duraton. Omit it and the adapter falls back to its provider SDK's conventional env var (for Anthropic, `ANTHROPIC_API_KEY`). Your model keys stay in your runner. ### Prompt caching `promptCache` opts a call into **the provider's own prompt cache**: the provider still runs the call, and the only thing that changes is what it bills for the part of the request it has already seen. It is a different control from the [inference cache](#inference-cache), which skips the provider call altogether, and the two can be set on the same call. It is a capability like `transcript` above - an adapter that can place a cache breakpoint declares `"prompt-cache"` - and the scope travels from the call site to the adapter unchanged: `GenerateOptions` (or the agent kit's `AgentOptions`) to `GenerateRequest.promptCache`. **The adapter alone decides where the breakpoints go.** `step.ai.loop` places none of its own; a turn is cached because the option reached the provider, not because the loop did anything to the request. The built-in `anthropic` adapter places them like this: | Scope | What the request carries | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"prefix"` | One breakpoint closing the static head: the last `system` block, or the last tool declaration when the call has no system prompt. | | `"conversation"` | That breakpoint, plus a top-level `cache_control` that re-places itself on the last cacheable block as the request grows, so turn N+1 reads turn N's transcript back instead of paying to process it again. | | unset | Nothing at all - the request is byte-identical to one made before the option existed. | No TTL is sent. `{ type: "ephemeral" }` is the only cache type the Messages API defines, and Duraton does not override the API's own default lifetime (five minutes, today), because a read refreshes the entry and an agent's next turn starts well inside that window. The `aisdk` adapter does **not** declare the capability. The AI SDK carries cache control on `providerOptions` keyed by the provider's own name, and the adapter never learns which provider `resolveModel` returned - so it cannot place a breakpoint the answering model would honour. Asking an adapter that has not declared it **fails the call** instead of sending it uncached. Both `step.ai.generate` and `agent()` throw a plain `Error`; there is no error class and no code, and the one stable thing to match on is the shared substring `cannot place a cache breakpoint` in the message. The refusal is deliberate: an adapter that dropped the field would still answer correctly, just at the full input price forever, and both cache axes would read zero - exactly what a cache that was asked for and missed looks like. `promptCache` is a `step.ai.generate` and `agent()` option only. It is not on `step.ai.loop`, where the `turn` you write owns the request it sends. ## Cost Duraton holds no model price list, so a call's `cost` is absent unless your runner supplies it. Pass `resolveCost` - the `CostSource` port - and each `step.ai` call is priced from the axes the journal already holds. Supplying it is what makes `cap: { maxCost }` bite; `maxTokens` needs nothing, because tokens are metered from every call. ```ts theme={null} import type { CostSource } from "@duraton/sdk/ai"; const PRICES: Record = { "claude-opus-4-8": { in: 5 / 1_000_000, out: 25 / 1_000_000 }, }; const resolveCost: CostSource = ({ model, tokensIn = 0, tokensOut = 0 }) => { const p = model ? PRICES[model] : undefined; return p ? tokensIn * p.in + tokensOut * p.out : undefined; // undefined leaves cost absent }; connect({ app: "support-app", workflows, resolveCost }); ``` Which step.ai call this was: "generate", "wrap", "embed", or "loop" (one agent turn). The model that served the call. Input tokens. Output tokens. Tokens read from the provider's own prompt cache, when it reports them. Tokens written to the provider's own prompt cache, when it reports them. Returning `undefined` leaves the cost absent - Duraton never fabricates a zero - and a call that already carries an explicit cost is left untouched. ## Cache store The [inference cache](#inference-cache) is backed by the **`AICache`** port, so the store is swappable. The default is `createMemoryCache()`: a process-local `Map` with per-entry TTL and LRU eviction, bounded at **1000** entries. Pass `cache` to `connect` to swap it - for a store shared across runner processes, say. ```ts theme={null} import { createMemoryCache } from "@duraton/sdk/ai"; connect({ app: "support-app", workflows, cache: createMemoryCache({ maxEntries: 10_000 }), }); ``` A live entry for this key, or undefined on a miss (absent or expired). Best-effort: a miss costs one real call, never correctness, so it must not throw. Store a call's product under key for ttlMs, overwriting any existing entry. The store is only ever consulted for a call that opted in with `cache` - its mere presence changes nothing. The cached completion is held **runner-side**: Duraton's journal records only the cache metadata (`hit`, `key`, `ageMs`), never the payload. A store you share across processes must seed its keys deliberately, since the default seed (the runner's app) assumes the process boundary isolates it. ## Related * [AI agents](/ai/ai-steps) - when to reach for `generate` vs `loop`, workflow tools, the agent patterns, and the replay rules. * [Cost controls](/ai/cost-controls) - `cap`, `tokenThrottle`, the inference cache, and fallback chains. * [Agent kit](/agent-kit) - `agent()` and `tool()` on top of `step.ai.loop`. * [Guardrails](/ai/guardrails) - the policy port, the adapters that ship, and how a tripwire halts a run. # REST client Source: https://docs.duraton.ai/reference/sdk/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. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; const duraton = createClient({ apiKey: process.env.DURATON_API_KEY, }); ``` Your Duraton base URL. A trailing slash is fine. The project API key, sent as a Bearer token. Override the fetch implementation - a custom agent, a test double. A **public** key can call the `GET`-backed methods. Writes - `events.send`, `cancel`, `pause`, `resume`, `replay`, `retryFromStep`, and `bulkReplay` - need a **secret** key. ## events ```ts theme={null} 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 ```ts theme={null} // 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](/reference/api/runs): `app`, `workflow`, `status`, `runType`, `eventId`, `session`, `replayOf`, `parentRunId`, `scoreName`, `minScore`, `maxScore`, `q`, `deep`, `since` (a `string` or `Date`), `sort`, `dir`, `limit`, `cursor`. `parentRunId` is the child-run filter: `runs.list({ parentRunId })` returns the runs one run's `step.runWorkflow` calls spawned, the reverse of the `parentRunId` each child carries. `find(opts)` is the single-run [lookup](/reference/api/runs#look-up-a-single-run): the same filters as `list` minus pagination, capped at one row, returning `Run | null` - `parentRunId` included, so `find({ parentRunId })` picks one child rather than a page of them. 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](/reference/api/runs) by default; pass `until` to also stop at a resting state like `waiting` or `paused`. ```ts theme={null} 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); ``` Extra non-terminal statuses to also stop at, e.g. \["waiting"]. The terminal set is always honoured on top, so the wait still resolves if the run finishes first. Reject if the run has not reached a stop status within this many ms. Poll cadence in ms. Aborting it rejects the pending wait. `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 ```ts theme={null} // A run's timeline: status transitions and ctx.log lines, in order. 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 ```ts theme={null} // Each returns the affected run; replay and retryFromStep 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..." }); ``` ### timeseries Run counts and latency bucketed over time - the numbers behind the console's Overview charts. ```ts theme={null} 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); } ``` Narrow the series to one app. Narrow the series to one workflow, by exact name. Only runs started at or after this timestamp. Bucket width in seconds. 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](/core/flow-control): how many events are currently coalescing in a debounce window, and how many are buffered toward a batch flush. ```ts theme={null} 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 ```ts theme={null} // 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. The decider is // recorded from the authenticated actor, so the decision cannot be attributed to anyone else. const open = await duraton.approvals.list({ status: "pending" }); await duraton.approvals.decide(open[0].id, { status: "approved" }); ``` ## workflows, apps, runners, health ```ts theme={null} 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. ```ts theme={null} 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](/reference/api#conventions). ## 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. ```ts theme={null} const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, fetch: myInstrumentedFetch, // any fetch-compatible implementation }); ``` # connect Source: https://docs.duraton.ai/reference/sdk/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. `connect` dials Duraton over a WebSocket and receives invokes on that socket, so the runner needs **no inbound URL, no framework, and no separate registration** - it works from behind NAT, from a laptop, or from a container with no ingress. A connect runner authenticates with your API key on the WebSocket upgrade. ```ts theme={null} import { connect, workflow } from "@duraton/sdk"; const ticketCreated = workflow<{ ticketId: string }>({ name: "ticket.created", handler: async (ctx) => ctx.step.run("refund", () => issueRefund(ctx.event.data.ticketId)), }); const handle = connect({ apiKey: process.env.DURATON_API_KEY, app: "support-app", workflows: [ticketCreated], }); process.on("SIGTERM", () => handle.close()); ``` ## ConnectOptions The workflows this runner serves. Their manifest is registered over the socket on every (re)connection. Your Duraton base URL; the WebSocket URL is derived from it. The app this runner belongs to; its workflow names are registered under that app. A stable runner id. Declare it to receive pinned runs; omit it to join the app's anycast pool. The project API key, sent as a Bearer token on the WebSocket upgrade. Swaps how a step.ai call turns a provider name into an adapter. See AI steps - Providers. Prices each step.ai call from its metering axes, so maxCost caps bite. See AI steps - Cost. The store backing opt-in inference caching. Inert until a call passes cache. See AI steps - Inference cache. Hooks that run before and after your handler: onInvoke enriches per-run log context, onResult transforms the result or error before it leaves the process. The SDK ships two ready-made middlewares: | Helper | Hook | What it does | | -------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------ | | `sanitizeErrors()` | `onResult` | Replaces a thrown error with one that keeps the message but drops the stack, so internal frames never leave the process. | | `bindLogContext(fn)` | `onInvoke` | Attaches `fn(info)` to every log line the run emits. A per-call field on `ctx.log` wins over a binding of the same key. | ## ConnectHandle `connect` returns synchronously - it does not await the socket - so the handle is available before the first invoke arrives. ```ts theme={null} const handle = connect({ app: "support-app", workflows: [ticketCreated] }); handle.close(); // closes the socket and stops reconnecting ``` Closes the socket and stops reconnecting. A closed handle never redials. ## Pinned and anycast runners Declaring a `runner` id makes the runner addressable: runs pinned to that id are delivered to it, and `ctx.step.runWorkflow({ runner })` can target it. Omitting `runner` puts the process in the app's anycast pool, where any replica may take any run - the right shape for stateless replicas you scale horizontally. ```ts theme={null} connect({ app: "agents", runner: "agent-node-1", workflows }); // receives runs pinned to agent-node-1 connect({ app: "support-app", workflows }); // one of N interchangeable replicas ``` Pinning is what lets a run land back on the machine that holds the state it needs (a local model, a mounted volume, an open session). See [runners](/core/runners). ## Reconnection The socket redials on close with exponential backoff, starting at **500ms** and doubling to a ceiling of **30s**; a successful open resets the backoff to 500ms. Each reconnection re-sends the workflow manifest, so a runner that comes back is immediately eligible for runs again - you do not re-register. ```ts theme={null} const handle = connect({ app: "support-app", workflows: [ticketCreated] }); // Duraton restarts, the socket drops: the runner redials at 500ms, 1s, 2s, 4s ... capped at 30s. ``` An invoke that was in flight when the socket dropped is not answered on that socket; its run retries under the workflow's [retry policy](/core/retries), landing on whichever runner is connected then. ## Liveness A dropped socket that fires `close` is easy - the reconnect above handles it. The harder failure is a **half-open** (zombie) socket: the TCP connection is silently dead (a proxy evicted an idle connection, a NAT mapping expired, the network black-holed), yet `readyState` stays `OPEN` and no `close` ever fires. Left alone, the runner looks connected while invokes go nowhere. The runner defeats this with an application-level heartbeat: * It sends a `ping` frame on an interval, **capped at the server's advertised heartbeat** so the cadence never drifts slower than the interval Duraton expects to hear from you on. * **Any** inbound frame - a `pong`, an invoke, a result - clears a pong watchdog, since any frame proves the socket still delivers server to client. * If the watchdog fires (no frame arrived within the pong timeout of a ping), the runner tears the socket down **immediately** rather than waiting on a `close` that a black-holed socket may never send, then reconnects. So a zombie surfaces within roughly `ping + pong timeout` (\~35s by default), not after the OS TCP timeout minutes later. These four knobs are configurable per call and via environment variables, with the env var names and defaults below. App-level ping cadence, capped at the server heartbeat. A half-open socket surfaces within pingIntervalMs + pongTimeoutMs. How long a ping waits for any inbound frame before the socket is treated as dead. The initial backoff before the first redial after a drop. The ceiling the doubling backoff is capped at. ```ts theme={null} connect({ app: "support-app", workflows: [ticketCreated], pingIntervalMs: 15_000, // probe more aggressively behind a short-idle proxy pongTimeoutMs: 5_000, }); ``` Duraton heartbeats the socket from its side too, and refreshes the runner's endpoint each time. That is what keeps the runner's last-seen time and its **Live** badge current in the console, and what turns it **Stale** if the process goes away without closing cleanly - see [runner liveness](/core/runners#liveness). ## Runtime `connect` uses the runtime's global `WebSocket` when one exists. Bun, Deno, Cloudflare Workers, and **Node 22+** ship it, so on those there is no extra dependency: ```ts theme={null} // Bun, Deno, Cloudflare Workers, Node 22+: no extra dependency. connect({ app: "support-app", workflows: [ticketCreated] }); ``` On **Node 18-21** there is no global `WebSocket`. The SDK detects this and falls back to the optional [`ws`](https://www.npmjs.com/package/ws) package, imported lazily so it stays out of the graph everywhere else - install it and `connect` uses it automatically: ```sh theme={null} npm install ws # only on Node 18-21 ``` Streaming AI steps (`step.ai.generate({ stream: true })`) use the live channel this socket provides. See [AI steps](/reference/sdk/ai-steps). # Defining workflows Source: https://docs.duraton.ai/reference/sdk/defining-workflows Declare a workflow in one object: name, triggers, retry policy, flow control, and handler - and get ctx.event.data typed for you. ## workflow Declares a workflow: its name, what triggers it, how it retries and runs under load, and the handler that does the work. It returns the definition unchanged - it exists for type inference over your event data, so a type parameter types `ctx.event.data`. ```ts theme={null} const ticketCreated = workflow<{ ticketId: string }>({ name: "ticket.created", triggers: [{ event: "ticket.created", if: "event.data.priority == 'high'" }], retry: { maxAttempts: 3 }, concurrency: { limit: 5, key: "ticketId" }, handler: async (ctx) => { const { ticketId } = ctx.event.data; // typed as { ticketId: string } return ctx.step.run("refund", () => issueRefund(ticketId)); }, }); ``` Unique workflow name. The function that does the work; receives the run context. What starts the workflow: event triggers (\{ event, if? }, where event may end in a trailing "\*" wildcard and if is a CEL filter) and cron triggers (\{ cron }, with an optional "TZ=Area/City" prefix). Omit for an implicit event trigger matching the workflow name. Per-workflow retry policy each step inherits (\{ maxAttempts, backoff?, initialDelayMs?, maxDelayMs? }). Cap on runs executing at once in a scope (\{ limit, key? }); over-limit runs wait and retry as slots free. Smooth cap on run starts (\{ limit, perMs, key? }); overflow is delayed into the future, never dropped. Shedding cap on run starts (\{ limit, perMs, key? }); overflow is dropped and the event response reports dropped: true. Collapse a burst to its last event (\{ periodMs, key? }); each new event slides the deadline and replaces the payload. Fold many events into one run (\{ maxSize, timeoutMs, key? }), flushing on whichever comes first; the run reads them as ctx.events. Dequeue this workflow's runs ahead of others (\{ shiftMs }) by treating them as enqueued that many ms earlier. At most one non-terminal run per key (\{ key?, mode? }); mode "cancel" (the default) cancels the running run, "skip" drops the new trigger. One run per derived key within a window (\{ key?, periodMs? }, periodMs defaulting to 24h); a duplicate is dropped and the response reports deduped: true. Hard per-run AI spend ceiling (\{ maxCost?, maxTokens? }); a run halts before the step.ai call that would cross it and fails with a BudgetError. Token-denominated rate limit on the workflow's AI spend (\{ tokens, perMs, key? }); each step's actual tokens are debited and new run starts for a drained key are spread into the future, holding no worker. Optional advisory manifest of the workflow's steps in declaration order, so a UI can render a progress bar ("step 12 of 18") and mark bookkeeping steps hidden. Advisory only: it never constrains execution and is diffed against the actually-executed steps. Optional advisory manifest of the workflow's tools, so a UI can list a tool that has never run. Advisory only: it never constrains what an agent actually calls. Optional advisory manifest of the workflow's agent loops - what bounds each one, never what it says. Advisory only: it never constrains execution. See Agent manifest below. Runs durably after the run has exhausted its retries and failed; receives the original event plus ctx.error. It cannot un-fail the run. ## Step manifest (optional) By default a step exists only once it has executed - Duraton discovers steps at runtime, so the run view has no plan to draw a progress bar from until each step has run. Declaring an optional `steps` manifest gives the UI the plan up front: the declared step names, their order, an optional `description`, and a `hidden` flag for bookkeeping steps. Reach for it only when a workflow has several steps to preview, describe, or hide. A single-step workflow, or one with nothing to describe or hide, needs no manifest - omit it and the run view still renders each step as it executes. The manifest's `name` must match the id you pass to `ctx.step.run`; that is the only reason a step id appears twice. ```ts theme={null} const checkout = workflow({ name: "checkout", steps: [ { name: "validate", description: "Check the cart" }, { name: "triage" }, { name: "audit", hidden: true }, // present for admin/debug, hidden from the customer view { name: "refund", description: "Return the money" }, ], handler: async (ctx) => { await ctx.step.run("validate", () => validate()); await ctx.step.run("triage", () => triage()); await ctx.step.run("audit", () => audit()); return ctx.step.run("refund", () => issueRefund()); }, }); ``` The step id. A declared step is matched to an executed step by this name. Optional human-readable label for the step. Excludes the step from the customer-facing progress view; it stays present for admin/debug views. The manifest is **advisory metadata for rendering, never a constraint on execution**: * It never gates a run, never fails a run for drift, and a workflow with no manifest behaves exactly as before. * The run view diffs declared vs executed steps by name: a declared step that has not run yet renders as **pending** (this is what powers "step 12 of 18"); a step that runs but was not declared still renders (**discovery wins**); a declared step that a conditional path skips simply stays pending / not-reached. * Steps execute at runtime in whatever order the handler runs them, including in parallel - the manifest order is presentation order only. * `hidden` steps are excluded from the customer-facing progress but returned in the read model so an admin/debug view can show them. The manifest is returned by `GET /workflows` and the `list_workflows` MCP tool alongside each workflow's triggers and flow control. ## Tool manifest (optional) The engine has no declarative knowledge of an agent's tools until a run calls one - `steps` above solves this for steps, and `tools` is the same fix for tools. Declaring an optional `tools` manifest lets a UI list a tool - its name, description, and parameter schema - before it has ever run, which is what a Builder-style tool picker needs. Build it with `@duraton/agent-kit`'s `toolManifest()`, from the same `tools`/`mcpServers` arrays the handler's `agent()` call uses - one array, two readers, so the manifest can never silently disagree with what the model actually sees: ```ts theme={null} import { agent, tool, toolManifest } from "@duraton/agent-kit"; const tools = [ tool({ name: "charge_card", description: "Charges the customer's card", inputSchema: { type: "object", properties: { amount: { type: "number" } } }, integration: "stripe", requiresApproval: true, handler: (input) => chargeCard(input), }), ]; const support = workflow({ name: "support.ticket", tools: toolManifest({ tools }), handler: (ctx) => agent(ctx, "support", { model: "claude-opus-4-8", prompt: "Resolve the ticket", tools }), }); ``` The tool name the model calls. Optional human-readable label. The tool's JSON Schema input, so a UI can render a parameter form. The tool's JSON Schema output, if declared. MCP behaviour hints (readOnlyHint, destructiveHint, idempotentHint, openWorldHint). They inform a default; they never gate. Whether this tool parks a run on a human before it executes. The integration this tool reaches, e.g. "google-calendar" - a free-text label, not a Credential id. A code-declared tool is the same code for every tenant, so it names an integration, never a specific tenant's credential. The agent() call this tool belongs to, for a workflow with more than one agent. Undefined for the common one-agent-per-workflow case. A hand-declared tool, or an attached MCP server. The manifest is advisory in the same way `steps` is - it never gates a run, never fails a run for drift, and a workflow with no manifest behaves exactly as before. AC#11's both directions hold: a tool the agent offers that is absent from the manifest still runs, and an entry the agent no longer offers never blocks a run. **An MCP server's tools are never enumerated here.** `toolManifest()` projects an attached `mcpServers` entry as a group placeholder - only its `name` and `source: "mcpServer"` - because discovery is a durable step at run time, not registration time. Contacting the server up front to fill the manifest would reintroduce the exact replay drift durable discovery exists to prevent (a server that adds or drops a tool mid-run must not change what a replay sees), and would make registration depend on a third party being reachable. A picker reading the manifest sees the server as an unexpanded group whose tools become known once a run has actually discovered them. Every flow-control field is optional and off by default; each one's semantics, keys, and overflow behaviour are in the [flow-control reference](/core/flow-control). `onFailure` is covered in [retries](/core/retries), triggers in [triggers](/core/triggers). ## RetryConfig The shape of the `retry` field - the per-workflow retry policy each step inherits. ```ts theme={null} retry: { maxAttempts: 3 } ``` Total attempts before the run fails, counting the first one. How the delay between attempts grows: fixed is constant; linear is initialDelayMs \* attempt; exponential is initialDelayMs \* 2^(attempt-1). The base delay before the second attempt, and the unit the backoff shape multiplies. An upper bound the computed delay is capped at, so exponential backoff cannot grow without limit. ## Agent manifest (optional) Everything that shapes an agent - its model, its ceilings, its guardrails, the conditions that stop it - lives in your code, so nothing about it is visible until a run has happened. Declaring an `agents` manifest reflects that configuration onto the workflow definition, so a console can list the agent, its model and what bounds it before it has ever run. Build it with the kit's `agentManifest()` from the same options object the handler passes to `agent()`, so the two cannot drift: ```ts theme={null} import { agent, agentManifest, tool, toolManifest } from "@duraton/agent-kit"; import { workflow, wallClock } from "@duraton/sdk"; const tools = [tool({ name: "refund", inputSchema: { type: "object" }, handler: issueRefund })]; const options = { model: "claude-opus-5", prompt: "Resolve the ticket.", maxIterations: 8, stopWhen: [wallClock({ within: "5m" })], tools, }; export const triage = workflow({ name: "triage", tools: toolManifest({ tools }), agents: [agentManifest("triage", options)], handler: (ctx) => agent(ctx, "triage", { ...options, prompt: ctx.event.data.body }), }); ``` The entry's `id` is the `agent()` step id, which is also the `agentId` its tools carry in the tool manifest - so the tools belonging to an agent are recoverable without listing their declarations twice. The manifest deliberately has no `prompt` and no `instructions` field. Prompt text never enters the durable record, and a definition row an operator can read is that same audience. Like the step and tool manifests it is one-way and advisory. Duraton stores it, re-emits it on the workflow read, and never reads it back to drive execution: an agent that no longer matches its entry still runs, and a workflow that omits it behaves exactly as before. # TypeScript SDK Source: https://docs.duraton.ai/reference/sdk/index Write durable agents in TypeScript: @duraton/sdk authors workflows, runs a runner, and calls Duraton from app code, all from one package. `@duraton/sdk` is how you author workflows, run them on a runner, and talk to Duraton from app code. Install it and import from the package root: ```sh npm theme={null} npm install @duraton/sdk ``` ```sh pnpm theme={null} pnpm add @duraton/sdk ``` ```sh yarn theme={null} yarn add @duraton/sdk ``` ```sh bun theme={null} bun add @duraton/sdk ``` ```ts theme={null} import { workflow, connect, createClient, NonRetriableError, RetryAfterError, PollTimeoutError, } from "@duraton/sdk"; ``` ## Entry points Every export is reachable from the package root. The scoped entries are subsets, for code that should not pull in the rest. ```ts theme={null} import { createClient } from "@duraton/sdk/client"; // no workflow authoring, no runner import { createMemoryCache } from "@duraton/sdk/ai"; // the AI ports and their adapters ``` | Entry | Contains | Use it when | | --------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | `@duraton/sdk` | Everything below, plus `workflow`, `connect`, `hashStepId`, and the error types. | Authoring workflows and running a runner. | | `@duraton/sdk/client` | `createClient`, `DuratonApiError`, and the REST DTOs. | App code that only calls Duraton - an admin tool, a frontend, a script. | | `@duraton/sdk/ai` | The `AIProvider` and `AICache` ports, their adapters, and the AI journal types. | Supplying your own model provider or cache store. | ## The surface workflow: name, triggers, retry, flow control. The handler context and the durable step API. Run a runner over an outbound WebSocket - no inbound URL. createClient: trigger events, read and control runs. step.ai: durable model calls, agent loops, providers, cache. # Steps Source: https://docs.duraton.ai/reference/sdk/steps Everything a handler can do durably: the step API - run, sleep, sleepUntil, waitForEvent, runWorkflow, emit, and approval - plus the handler context. ## The handler context Every handler receives a `StepContext`: the triggering event, the durable `step` API, and the run's metadata. ```ts theme={null} workflow<{ ticketId: string }>({ name: "ticket.created", handler: async (ctx) => { ctx.log.info("refunding", { ticketId: ctx.event.data.ticketId, attempt: ctx.attempt }); return ctx.step.run("refund", () => issueRefund(ctx.event.data.ticketId)); }, }); ``` The triggering event; event.data has the type passed to workflow. The durable step API - see below. ctx.webhook.send(id, \{ url, data? }): a durable outbound POST with retries and a per-attempt log. Structured, replay-safe logging: ctx.log(msg, fields) plus .debug/.info/.warn/.error. See Logging. This run id. Retry attempt, starting at 1 and increasing on each retry. The app this run belongs to. The runner this run is pinned to; the empty string when the run is anycast. Set only on a batched workflow: the coalesced events. event is events\[0]. Set only inside an onFailure handler: the terminal error that failed the source run. ## step Every step takes a stable `id`, unique within the workflow. The result is recorded under that id, and on replay after a crash or retry a completed step returns its saved result instead of running again. Run fn once and memoize its result under id. Overload: run(id, input, fn) also records an explicit input. Durable model calls: generate, wrap, embed, loop. See AI steps. Record a deliberately bypassed step as a terminal "skipped" step; the optional reason is stored as its output. Durable: id must be stable across replays. Suspend the run for a relative duration - a string like "30s"/"1h", or a number of ms. Suspend the run until an absolute time - a Date, an ISO string, or epoch ms. Suspend until a matching event arrives, returning its data; resolves to null when timeout elapses first. Optional CEL if filters on the event payload so the run resumes only on the correlated event. Re-check an external resource until it is ready, sleeping durably between checks, without spending the step's retry budget. Returns the ready value; throws PollTimeoutError when the deadline passes. Invoke another workflow as a child run and wait for its result. Emit an event from inside a run. Park the run on a human decision and resolve to it on resume. onTimeout says what the timeout does: escalate (default), approve, reject, or fail. ### run The unit of durable work: `fn` executes once and its result is memoized under `id`. The three-argument overload also records an explicit `input`, which the run inspector shows on the step's Input tab and hands to `fn`. ```ts theme={null} const triage = await ctx.step.run("triage", () => classify(ctx.event.data.subject)); const reply = await ctx.step.run("reply", { ticketId, tone: "apologetic" }, (input) => drafts.create(input.ticketId, input.tone), ); ``` ### sleep and sleepUntil The run is suspended, not blocked: it holds no worker while it waits, and it survives a restart. ```ts theme={null} await ctx.step.sleep("cool-off", "30s"); await ctx.step.sleepUntil("follow-up", new Date("2026-08-01T00:00:00Z")); ``` ### waitForEvent Suspends until an event with the given name arrives, returning its data - or `null` when `timeout` elapses first, which is how you branch on the timeout. ```ts theme={null} const paid = await ctx.step.waitForEvent<{ amount: number }>("await-payment", { event: "payment.received", timeout: "24h", }); if (paid === null) return ctx.step.run("expire", () => expireOrder(ticketId)); ``` ### poll Re-checks an external resource until it is ready, sleeping durably between checks. The `probe` reads the resource and returns its value once ready, or a "not ready yet" signal otherwise; a not-ready check is a normal successful read, so it never spends the step's retry budget. Returns the ready value, or throws `PollTimeoutError` when `timeout` elapses first. ```ts theme={null} const record = await ctx.step.poll("provision", () => fetchRecordOrNull(), { every: "5s", timeout: "10m", until: (v) => v != null, }); ``` Delay between checks - a duration string like "5s" or a number of milliseconds. Overall deadline for the whole wait. Once it passes, poll throws PollTimeoutError. Readiness predicate. When omitted, a non-null probe value is treated as ready. Safety cap on the number of checks. A probe that throws is a genuine error, not a not-ready signal: it retries under the step's [retry policy](/core/retries) and fails the run if it exhausts its attempts. A `PollTimeoutError` fails the run and routes to `onFailure`; wrap the call in `try`/`catch` to treat a missed deadline as a non-fatal branch instead. See [Poll until ready](/core/steps#step-poll). Each check costs two durable steps (a `step.run` probe plus a `step.sleep` gap), not a free suspension - `every: "5s"` over `timeout: "10m"` is up to 120 checks, 240 durable steps. Pick the widest `every` the resource's provisioning time tolerates. ### runWorkflow Invokes another workflow as a linked child run and waits for its result. Omit `app` to resolve the name in the caller's app first, then any app in the project; set `runner` to pin the child to a specific runner. Pass `tags` to attach [run tags](/reference/api/runs#run-tags) to the child - it also inherits the parent run's tags, with the child's value winning on a shared key. ```ts theme={null} const score = await ctx.step.runWorkflow<{ risk: number }>("risk-check", { name: "fraud.score", app: "risk", data: { ticketId }, tags: { stage: "risk-check" }, }); ``` ### emit Emits an event from inside a run, which fans out to whatever triggers match it. Omit `app` to broadcast project-wide; set it to narrow the event to one app's triggers. ```ts theme={null} await ctx.step.emit("notify", { name: "ticket.shipped", app: "notifications", data: { ticketId, carrier: "ups" }, }); ``` ### approval Parks the run on a human decision. The run suspends in `needs_attention` - checkpoint kept, no worker held - until it is approved or denied, then resolves. `args` on the result are the effective arguments: the decider's edits when they changed them, otherwise the proposed ones. A `timeout` sets a deadline and `onTimeout` says what reaching it does - by default it escalates and keeps waiting. See [Approvals](/ai/approvals). ```ts theme={null} const decision = await ctx.step.approval<{ amount: number }>("refund-gate", { tool: "issue-refund", args: { priority: "high" }, risk: "high", summary: "Refund ticket A1 in full", }); if (decision.status === "approved") { await ctx.step.run("refund", () => stripe.refunds.create({ amount: decision.args.amount })); } ``` ## hashStepId Returns the stable hash Duraton keys a step's memoized result by. It is exposed for tooling that correlates a step id with its recorded entry. ```ts theme={null} import { hashStepId } from "@duraton/sdk"; const key = hashStepId("triage"); // the hash the "triage" step's result is stored under ``` # Protocol reference Source: https://docs.duraton.ai/reference/wire-protocol How Duraton talks to a runner - the endpoints, the execution model, routing, status codes, and version negotiation. You don't need this to build workflows - the [SDK](/core/workflows) handles all of it. Read it to understand what your runner is doing. This is what happens between **Duraton** and a **runner** (your code plus an SDK), over the Connect WebSocket. ## How Duraton drives a workflow Duraton never holds your workflow in memory. It makes progress by sending your runner an invoke over its socket, once per step, sending along the results of every step that has already completed. Your handler runs from the top each time: completed steps return their saved result, and the first unfinished step does real work and reports back. Duraton saves that result and calls again, until the handler returns. This is why a runner is stateless and a run survives a Duraton restart: all progress lives in Duraton's store and is replayed to the runner on each call. A pass ends in one of two ways. The handler returns, and Duraton marks the run complete; or the handler reaches work it has not done yet, and Duraton records what it discovered, does or schedules that work, and invokes again. A step that is already running - a sleep, a wait, a child run - comes back reported as in progress, and your handler waits on it rather than starting it twice. Steps are matched across passes by their id, which is why **step ids must be stable and the handler must be replay-deterministic**. [Steps](/core/steps) covers the rules that follow from that, including how a reused id inside a loop is disambiguated. `hashStepId` in the SDK ([reference](/reference/sdk/steps)) computes the key a given step id is stored under, if you ever need to correlate one yourself. ## Endpoints | Method + path | On | Purpose | | ----------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /events` | Duraton | Ingest an event: resume any `waitForEvent` waiters and fan out to every workflow whose triggers match. Request and response are documented at [Events](/reference/api/events). | | `GET /runs`, `GET /runs/{id}`, `GET /runs/{id}/steps` | Duraton | Inspect [runs and steps](/reference/api/runs). | | `GET /workflows` | Duraton | List [registered workflow definitions](/reference/api/workflows). | | `GET /runners` | Duraton | List [registered runners](/reference/api/runners). Filter `?app=`. | | `GET /events`, `GET /events/{id}` | Duraton | The [event log](/reference/api/events): each ingested event with what it triggered or woke. | | `GET /events/stream` | Duraton | Live tail of ingested events as Server-Sent Events. | | `GET /connect` | Duraton | WebSocket upgrade for the Connect transport. The runner dials this, registers its app and workflows (the shape [`GET /workflows`](/reference/api/workflows) reads back), and receives invokes over the socket. Each pass answers `200` (done) or `206` (more work). | ## Logs A pass carries back the structured lines your handler emitted via `ctx.log`, on every status (`200`, `206` and `500` - lines written before a throw still ship), so a log is never lost to the path a pass took. Because the handler body re-runs on every pass, a handler-level log would re-emit each time. Duraton deduplicates it so it persists once, and keys an in-step log on that step's attempt, so a retried step's logs stay distinct per attempt. Read them back via [`GET /runs/{id}/logs`](/reference/api/runs#run-logs). ## Routing (`{app, runner?}`) A run is owned by an app and executed by one of that app's registered runners (an app may have many). The `runner` id is the routing handle: * **Anycast** (no `runner`): each invoke goes to any one registered runner of the app. Runners are stateless and the full step memo is resent every invoke, so different passes may safely hit different replicas. * **Pinned** (`runner` set on the event): routed only to that runner id, invoke after invoke. Routing is the only thing the pin changes - the no-runner behaviour is identical for both. If no capable runner is registered when a run needs one (none at all for an anycast run, or that specific id for a pinned run), the run **parks and retries** rather than failing on the first miss, so the event-before-register race and a pinned runner's rolling restart both self-heal. The wait is bounded at **5 minutes**; if it elapses with still no capable runner, the run **fails terminally** with a reason naming the missing workflow (and the runner id, for a pin). An orphaned run - its app scaled to zero, or its workflow served by no runner - therefore still reaches a terminal state instead of parking forever. A runner that omits an id is keyed by its connection. A child workflow inherits its parent's pin only in the same app. `ctx.runner` carries the pin to your handler (empty for anycast). ## Connect transport (WebSocket) Your runner dials Duraton over a WebSocket (`GET /connect`) and receives invokes on that socket, so it needs **no inbound address** - it works for an agent on a node behind NAT. In the SDK this is `connect({ url, app, runner?, workflows })`. The stable `runner` id is part of the handshake, so a **connected runner is pinnable** by `{app, runner}`. A dropped socket is detected by heartbeat and the runner is evicted from routing until it reconnects; the SDK reconnects automatically. See [`connect()`](/reference/sdk/connect). ## Status codes | Code | Meaning | | ----- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `200` | The handler returned. The run is complete. | | `206` | The pass discovered more work. Duraton records it and invokes again. | | `4xx` | The runner rejected the request: an unknown workflow, an incompatible protocol version. Not retried as a step. | | `5xx` | The runner failed to answer. Duraton retries the invoke on the transport budget, counted separately from the workflow's own step retries. | A response body is capped at **1 MiB**; a larger one fails the pass rather than truncating silently. When a run fails terminally and its workflow registered `onFailure: true`, Duraton marks the run `failed` and invokes the onFailure handler as a separate follow-on run. The failed run is retained: queryable via `GET /runs?status=failed` and redrivable via `POST /runs/{id}/replay`. See [Retries](/core/retries). ## Protocol version Duraton and runner share a single integer wire version (currently `1`). Each side advertises it and checks the peer's in the Connect handshake, so a breaking wire change fails loudly instead of misparsing. The rule is **lenient on absence, strict on a present mismatch**: a peer that sends no version is assumed compatible, so the field is additive and never breaks an older peer, but a version that is present and differs is rejected: the socket closes. The SDK sets this for you - you only encounter it if a runner and Duraton are on incompatible releases. # AI quickstart Source: https://docs.duraton.ai/start/ai-quickstart Make your first model call crash-safe: add a durable AI step, point it at your provider, and watch its spend, tokens, and latency land in the console. Turn a model call into a durable step, trigger it, and watch its spend, tokens, and latency roll up in the console's **AI** view. This picks up where the [Quickstart](/start/quickstart) leaves off - it assumes you already have a project, an API key, and a connected runner. If you don't, start there first. ## 1. Add an AI step [`ctx.step.ai.generate`](/reference/sdk/ai-steps#step-ai-generate) makes a model call a durable step: its result is recorded once under the step id, so a retry after a crash returns the saved result instead of calling - and paying for - the model again. ```ts title="runner.ts" theme={null} import { workflow } from "@duraton/sdk"; export const triageTicket = workflow<{ subject: string }>({ name: "ticket.created", handler: async (ctx) => { const { text } = await ctx.step.ai.generate("classify", { model: "claude-opus-4-8", prompt: `Classify this ticket: ${ctx.event.data.subject}`, }); return { text }; }, }); ``` ## 2. Set your provider key The built-in provider is Anthropic. Your **runner** makes the model call, so the key stays with your runner - Duraton meters tokens but never sees your key, your prompt, or the response. Set it in the runner's environment: ```sh theme={null} export ANTHROPIC_API_KEY="sk-ant-..." ``` Duraton is bring-your-own-keys. Omit `apiKey` on the call and the provider SDK reads its conventional env var (`ANTHROPIC_API_KEY`); pass `apiKey` per call to override it. Either way the key is used for that one call and is never recorded in the run history. This page is the SDK path - your own runner, your own key. A workflow built without code has no runner to hold an env var, so it resolves a key you add once under [Credentials](/integrations/credentials) instead - same guarantee, same "used for one call, never recorded" rule, different placement. ## 3. Trigger it and watch spend land Send the event your workflow listens for - with the SDK client, or over the [REST API](/reference/api/events): ```ts theme={null} import { createClient } from "@duraton/sdk"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); await duraton.events.send({ name: "ticket.created", data: { subject: "Refund not received" }, }); ``` ```sh theme={null} curl -X POST "$DURATON_URL/events" \ -H "Authorization: Bearer $DURATON_API_KEY" \ -d '{"name":"ticket.created","data":{"subject":"Refund not received"}}' ``` As the AI step runs, open the **AI** view in the console. Once the first call is recorded it fills in: total **spend** and **tokens** for the window, **average latency**, **cache hit** rate, and breakdowns of cost by hour, by model, and by workflow. ## Read it over MCP An AI agent reading your project sees the same rollup: Duraton exposes the AI spend summary as the [`ai_spend`](/integrations/mcp-server) MCP tool, so an assistant can pull window totals and the by-model / by-workflow breakdowns without the console. ## Next steps generate, wrap, embed, and loop - the full step.ai reference. Durable agent loops and the classic patterns, made crash-safe. Cap and throttle model spend per run and per workflow. Token and cost rollups, sessions, and traces for every run. # Build your first agent Source: https://docs.duraton.ai/start/first-agent Sign in, add a model provider key, and publish an agent from the console - no code. No coding required. This walks through building and testing an AI agent entirely in the console - the first step toward turning a problem AI can solve into something you can offer other people. ## 1. Sign in Sign in to the console at [console.duraton.ai](https://console.duraton.ai). ## 2. Add a model provider key There is no default model - an agent names its provider and model explicitly, and runs on a key you provide. In the sidebar, open **Credentials**, then **New credential**. | Field | What to enter | | ------ | ------------------------------------------- | | Type | **Anthropic API Key** or **OpenAI API Key** | | Name | Anything that helps you tell it apart later | | Secret | The key itself | Submit **Add credential**. ## 3. Create the agent In the sidebar, open **Agents**, then **New agent**. The form has four sections: | Section | Fields | | ------------ | ---------------------------------------------------------------------------- | | Identity | **Name**, **Description** | | Instructions | **Prompt**, **Instructions** (optional) | | Model | **Provider**, **Model**, **API key** | | Limits | **Max iterations** (required), **Max tokens** and **Temperature** (optional) | The **Model** section is where the model is named, not defaulted: pick a **Provider**, a **Model** for that provider, then the **API key** you added in step 2 - the list is filtered to credentials matching the provider you chose, with a **Manage API keys** link if you need to add another. Changing the provider clears the picked model and key, so they can't drift out of sync. Submit **Create agent**. ## 4. Try it, before publishing The agent detail page opens with a **Draft** badge. Use **Test this agent** on the right to send it a message and see how it responds. Nothing here is saved or published - it's a scratch conversation, reset on reload, so you can iterate freely on the prompt and instructions. ## 5. Publish Once you're happy with it, click **Publish**. The badge changes to **Live**, and every publish is recorded under **Releases** on the same page, so you can see what shipped and when. Editing a published agent's form and submitting again saves a new draft (the button reads **Save draft**) without touching what is live - publish again when you want the draft to go live. # Migrating from Inngest Source: https://docs.duraton.ai/start/migrating-from-inngest Map Inngest functions, steps, and flow control onto Duraton - and the three differences that silently break a naive port. Duraton's model lines up closely with Inngest's: an event triggers a durable function, work happens in replay-safe steps, and flow control lives in the definition. Most of a port is mechanical renames - `createFunction` becomes `workflow`, `step.run` stays `step.run`. This guide gives you the renames first, then the **three differences that compile fine and fail at runtime**, which are the ones worth reading before you start. ## Core primitives | Inngest | Duraton | Notes | | --------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `inngest.createFunction({ id, triggers }, handler)` | `workflow({ name, triggers?, handler })` | Duraton's `name` is both the identifier **and** the implicit event trigger, so a workflow named `order.created` needs no explicit trigger. Drop the separate `id`. | | handler args `{ event, step }` | one `ctx` object (`ctx.event`, `ctx.step`) | Your payload is `ctx.event.data`. | | `step.run(id, fn)` | `step.run(id, fn)` | Identical. Duraton also has `step.run(id, input, fn)` to record the step's input. | | `step.sleep(id, "10s")` | `step.sleep(id, "10s")` | Identical. | | `step.sleepUntil(id, date)` | `step.sleepUntil(id, date)` | Identical. | | `step.waitForEvent(id, { event, timeout, match })` | `step.waitForEvent(id, { event, timeout, if? })` | Duraton has **no `match` shorthand**: express the correlation as an `if` CEL filter, e.g. `if: "event.data.orderId == '..'"`. | | `step.invoke(id, { function })` | `step.runWorkflow(id, { name })` | Invoke a child run by workflow `name` and await its result. | | `step.sendEvent(id, event)` | `step.emit(id, { name, data })` | Renamed - and the shape differs. See [the silent bites](#what-breaks-silently). | | `inngest.send(event)` (outside a function) | `duraton.events.send({ name, app, data })` | Client-side ingest. | ## Before / after An Inngest function: ```ts theme={null} export const orderCreated = inngest.createFunction( { id: "order-created", retries: 3 }, { event: "order.created" }, async ({ event, step }) => { const { orderId } = event.data; const charge = await step.run("charge", () => chargeCard(orderId)); await step.sleep("settle", "10s"); const ship = await step.run("ship", () => bookShipment(orderId)); return { orderId, charge, ship }; }, ); ``` The Duraton equivalent - the handler body is unchanged; the wrapper differs, and the runner dials out with `connect()` instead of exposing an HTTP route: ```ts theme={null} import { connect, workflow } from "@duraton/sdk"; interface OrderData { orderId: string; } const orderCreated = workflow({ name: "order.created", // id + implicit event trigger in one retry: { maxAttempts: 3 }, // Inngest `retries: 3` handler: async (ctx) => { const { orderId } = ctx.event.data; const charge = await ctx.step.run("charge", () => chargeCard(orderId)); await ctx.step.sleep("settle", "10s"); const ship = await ctx.step.run("ship", () => bookShipment(orderId)); return { orderId, charge, ship }; }, }); connect({ app: "shop", workflows: [orderCreated] }); ``` ## Flow control Every knob has a counterpart, with two renames and one shape change to watch: | Inngest config | Duraton config | Notes | | ---------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------- | | `concurrency: { limit, key }` | `concurrency: { limit, key }` | Same shape; `key` semantics differ (see below). | | `idempotency: "event.data.cartId"` | `idempotency: { key: "cartId", period? }` | Object, not a bare string; default `period` is 24h. | | `rateLimit: { limit, period, key }` | `rateLimit: { limit, per, key }` | `period` -> `per`. Sheds the trigger when over the limit. | | `throttle: { limit, period, key }` | `throttle: { limit, per, key }` | `period` -> `per`. Spreads new run starts rather than shedding. | | `debounce: { period, key }` | `debounce: { period, key }` | Same shape; only the last event's data survives the window. | | `batchEvents: { maxSize, timeout, key }` | `batch: { maxSize, timeout, key }` | Renamed `batchEvents` -> `batch`. | | `priority: { run: "" }` | `priority: { shift }` | Duraton shifts scheduling by a fixed duration; there is no per-event priority expression. | Duraton also has flow control Inngest does not: `singleton`, plus the AI-spend controls `cap` and `tokenThrottle`. See [Flow control](/core/flow-control). ## What breaks silently These three compile cleanly against the Duraton types but behave differently from Inngest at runtime - the ones that actually bite during a port. ### 1. Key fields are a field **path**, not an expression In Inngest, `idempotency` (and the `key` on `concurrency` / `rateLimit`) is a **CEL expression** evaluated against the event - you can compute and concatenate: ```ts theme={null} // Inngest: an arbitrary expression is valid idempotency: `event.data.promptHash + "-" + event.data.userId` ``` In Duraton, every key field is a **dotted field path into the event data** - a lookup, never an expression. Duraton resolves it by walking the path (`user.id` reads `data.user.id`); a missing field or a non-scalar value yields the workflow-global scope, and there is **no arithmetic, concatenation, or CEL**: ```ts theme={null} // Duraton: a dot-path only idempotency: { key: "userId", period: "24h" } // reads event.data.userId ``` If you need a composite key, compute it **upstream** and emit it as a single field on the event, then point the path at that field. This applies to `concurrency.key`, `rateLimit.key`, `throttle.key`, `debounce.key`, and `batch.key` too - all of them are paths, so any Inngest expression key must be flattened into one event field first. ### 2. Middleware has a different shape Inngest middleware is a nested factory registered on the client, with per-run, per-step, and input/output transform hooks: ```ts theme={null} // Inngest const mw = new InngestMiddleware({ name: "my-mw", init() { return { onFunctionRun() { return { beforeExecution() {}, afterExecution() {}, transformOutput() {}, }; }, }; }, }); const inngest = new Inngest({ id: "app", middleware: [mw] }); ``` Duraton middleware is a **flat object with two hooks**, passed straight to `connect()` - there is no client object to register on and no step-level interception: ```ts theme={null} // Duraton import { connect, type Middleware } from "@duraton/sdk"; const mw: Middleware = { onInvoke(info) { // runs before your handler on every pass; return bindings to attach to logs return { runId: info.runId, attempt: info.attempt }; }, onResult(info, outcome) { // runs after the handler settles; return a replacement Outcome to transform it return outcome; }, }; connect({ app: "shop", workflows: [orderCreated], middleware: mw }); ``` `onInvoke` covers Inngest's `onFunctionRun` + `beforeExecution`; `onResult` covers `transformOutput` for the terminal result or error. There is **no per-step hook** and **no `transformInput`** - anything that wrapped individual steps has no Duraton equivalent and must move into the step body. The built-in `sanitizeErrors()` and `bindLogContext()` helpers ship as ready-made middleware. ### 3. There is no `step.sendEvent` - it's `step.emit` Inngest sends events from inside a function with `step.sendEvent`, which also accepts an array of events: ```ts theme={null} await step.sendEvent("notify", { name: "order.shipped", data: { orderId } }); ``` Duraton's method is `step.emit`, and it sends **one** event per call - `name` is the event name, with an optional `app` to target a single app (omit it to broadcast project-wide) and an optional `dedupeId`: ```ts theme={null} await ctx.step.emit("notify", { name: "order.shipped", data: { orderId } }); ``` Outside a run, the analog of `inngest.send` is `duraton.events.send({ name, app, data })`. See [Events](/core/workflows). # Quickstart Source: https://docs.duraton.ai/start/quickstart Get your first durable run finishing in five steps: create a project, issue a key, write a workflow, connect a runner, and trigger it. Five steps: create a project, issue a key, write a workflow, connect a runner, trigger it. Duraton is in **beta**. Install from the `@next` tag: `@duraton/sdk@next`. A plain `npm install @duraton/sdk` resolves to the `latest` tag, which currently lags behind and is missing the recent features (run tags, `bulkCancel`, `watchFiltered`, `step.skip`, per-step retry, and more) * with no error to tell you. Use `@next` until `latest` catches up. ## 1. Create your project Sign up at [console.duraton.ai](https://console.duraton.ai). A workspace is created for you with a first [project](/core/projects) inside it, and the **Overview** walks you through the steps below. A project is the isolated slice - its own runs, events, keys, and runners; a workspace holds many. You can add more projects - and invite members - later. ## 2. Issue an API key Keys are scoped to a project. In the console, open **API Keys** and issue a **secret** key. A secret key is read + write - your runner connects and reports results with it. It's shown once, so copy it now. Issuing an API key in the console Set it in your runner's environment: ```sh theme={null} export DURATON_API_KEY="dtn_live_..." ``` ## 3. Write a workflow Install the SDK. It runs on [Node.js](https://nodejs.org) and [Bun](https://bun.com): ```sh npm theme={null} npm install @duraton/sdk@next ``` ```sh pnpm theme={null} pnpm add @duraton/sdk@next ``` ```sh yarn theme={null} yarn add @duraton/sdk@next ``` ```sh bun theme={null} bun add @duraton/sdk@next ``` A workflow is a function triggered by an event. Wrap each unit of work in a step so it runs once and its result is remembered. ```ts title="runner.ts" theme={null} import { workflow } from "@duraton/sdk"; interface TicketData { ticketId: string; } const ticketCreated = workflow({ name: "ticket.created", retry: { maxAttempts: 3 }, handler: async (ctx) => { const { ticketId } = ctx.event.data; const triage = await ctx.step.run("triage", () => ({ category: "billing", priority: "high", })); await ctx.step.sleep("cool-off", "10s"); const refund = await ctx.step.run("refund", () => ({ refundId: `re_${ticketId}`, })); return { ticketId, triage, refund }; }, }); ``` ## 4. Connect a runner `connect()` dials Duraton over an outbound WebSocket. There is no port to expose, no public URL, and no separate registration - it works from a laptop, a container, or a box behind NAT. It reads `DURATON_API_KEY` from the environment you set in step 2. `app` groups a runner's workflows under a name the console and other runners see; skip it and it defaults to `"default"`. ```ts title="runner.ts" theme={null} import { connect } from "@duraton/sdk"; connect({ app: "support-app", workflows: [ticketCreated] }); ``` ```sh Node.js theme={null} npx tsx runner.ts ``` ```sh Bun theme={null} bun run runner.ts ``` Open **Apps** in the console: the runner is listed as connected, with its workflows. Connected apps and their runners in the console ## 5. Trigger it Send the event the workflow listens for - with the SDK client, or over the [REST API](/reference/api/events) authenticated with your key: ```ts theme={null} import { createClient } from "@duraton/sdk"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); await duraton.events.send({ name: "ticket.created", app: "support-app", data: { ticketId: "T-421" }, }); ``` ```sh theme={null} curl -X POST "$DURATON_URL/events" \ -H "Authorization: Bearer $DURATON_API_KEY" \ -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421"}}' ``` Open **Runs** in the console. `triage` completes, the run waits out the sleep, then `refund` completes and the run succeeds - streaming in live as it executes. Runs in the console ## 6. Restart the runner mid-run While the run is sleeping, stop your runner (Ctrl-C) and start it again the same way you ran it above. Duraton holds the run and re-invokes when the runner reconnects. `triage` does **not** run a second time - its result was recorded on the first pass, so the run resumes at `refund`. ## Next steps Runnable snippets for retries, cron, approvals, replay, and AI. step.run, step.sleep, and what to wrap. Control how failed steps retry. Make a model call a durable step. # Recipes Source: https://docs.duraton.ai/start/recipes One complete, paste-and-run workflow per task - making a model call durable, parking a run on a human decision, retrying, waiting, scheduling, replaying, and webhooks. Each section below is one task, with a complete workflow or client call you can paste into the [quickstart](/start/quickstart) runner and run as-is. Nothing is elided. Every link goes to the reference for that capability. Not sure which you need? [Start with your goal](/). ## Retry a flaky call, fail fast on a bad one A step retries on its policy. `NonRetriableError` ends the run on the first attempt - retrying a declined card cannot fix it - and `RetryAfterError` retries on a delay the upstream dictated. ```ts theme={null} import { workflow, NonRetriableError, RetryAfterError } from "@duraton/sdk"; export const capture = workflow<{ ticketId: string; amount: number }>({ name: "ticket.capture", retry: { maxAttempts: 4 }, handler: async (ctx) => { await ctx.step.run("validate", () => { if (ctx.event.data.amount <= 0) throw new NonRetriableError("amount must be positive"); }); return await ctx.step.run("triage", async () => { const res = await fetch("https://api.example.com/triage", { method: "POST" }); if (res.status === 429) throw new RetryAfterError("rate limited", "30s"); if (!res.ok) throw new Error(`gateway ${res.status}`); return await res.json(); }); }, onFailure: async (ctx) => { await ctx.step.run("void-hold", () => voidHold(ctx.event.data.ticketId, ctx.error?.message)); }, }); ``` `onFailure` runs durably after the run has exhausted its retries and failed. It receives the original event plus `ctx.error`, and cannot un-fail the run. [Retries](/core/retries) ## Fan one event out to many workflows Every workflow subscribed to `user.signup` gets its own run. A CEL `if` filter narrows a subscription to the events that match it. ```ts theme={null} export const welcome = workflow<{ userId: string; plan: string }>({ name: "signup.welcome", triggers: [{ event: "user.signup" }], handler: async (ctx) => ctx.step.run("email", () => sendWelcome(ctx.event.data.userId)), }); export const welcomePro = workflow<{ userId: string; plan: string }>({ name: "signup.welcome-pro", triggers: [{ event: "user.signup", if: 'event.data.plan == "pro"' }], handler: async (ctx) => ctx.step.run("concierge", () => bookOnboarding(ctx.event.data.userId)), }); ``` A free signup starts one run; a pro signup starts two. [Events](/core/workflows) · [Triggers](/core/triggers) ## Pause for hours, then continue `step.sleep` parks the run - it holds no process and no connection. `step.waitForEvent` parks it until a matching event arrives, and resolves to `null` if the timeout matures first. ```ts theme={null} export const awaitApproval = workflow<{ ticketId: string }>({ name: "ticket.await-approval", handler: async (ctx) => { const decision = await ctx.step.waitForEvent<{ approver: string }>("await", { event: "approval.granted", timeout: "48h", }); if (decision === null) { return await ctx.step.run("expire", () => cancelOrder(ctx.event.data.ticketId)); } await ctx.step.sleep("cool-off", "1h"); return await ctx.step.run("refund", () => issueRefund(ctx.event.data.ticketId)); }, }); ``` [Steps](/core/steps) ## Run on a schedule A `cron` trigger needs no event. `singleton: { mode: "skip" }` drops a tick that would overlap a run still in flight. ```ts theme={null} export const rollup = workflow<{ cron: string; scheduledFor: string }>({ name: "metrics.rollup", triggers: [{ cron: "@every 1m" }], singleton: { mode: "skip" }, handler: async (ctx) => { ctx.log.info("rollup tick", { scheduledFor: ctx.event.data.scheduledFor }); return await ctx.step.run("aggregate", () => rollupHour()); }, }); ``` Each scheduled run's input is `{ cron, scheduledFor }`. [Triggers](/core/triggers) ## Shape a burst of events into runs Flow control is declared on the workflow and applied before a run starts. `debounce` coalesces a burst into one run carrying the last event's data; `batch` accumulates events into one run delivered as `ctx.events`; `rateLimit` drops what is over the cap. ```ts theme={null} export const reindex = workflow<{ documentId: string }>({ name: "search.reindex", debounce: { periodMs: 5_000, key: "documentId" }, handler: async (ctx) => ctx.step.run("index", () => reindexDoc(ctx.event.data.documentId)), }); export const flush = workflow<{ metric: string; value: number }>({ name: "metrics.flush", batch: { maxSize: 50, timeoutMs: 5_000 }, handler: async (ctx) => { const points = (ctx.events ?? []).map((e) => e.data); return await ctx.step.run("write", () => writePoints(points)); }, }); ``` The eight controls - `concurrency`, `throttle`, `rateLimit`, `debounce`, `batch`, `priority`, `singleton`, `idempotency` - and what each does to an event over its cap: [Flow control](/core/flow-control) ## Call a child workflow, in parallel `step.runWorkflow` starts another workflow as a child run and returns its output. Steps that do not depend on each other run concurrently under `Promise.all`. ```ts theme={null} export const placed = workflow<{ ticketId: string; amount: number }>({ name: "ticket.placed", handler: async (ctx) => { const [refund, notified] = await Promise.all([ ctx.step.runWorkflow<{ refundId: string }>("refund", { name: "support.refund", app: "support", data: ctx.event.data, }), ctx.step.run("notify", () => notifyRequester(ctx.event.data.ticketId)), ]); await ctx.step.emit("receipt", { name: "receipt.requested", app: "support", data: { ticketId: ctx.event.data.ticketId, refundId: refund.refundId }, }); return { refund, notified }; }, }); ``` Passing `app` addresses the child to that app exactly; omit it to resolve the name in the caller's app first. [Workflows](/core/workflows) ## Make a model call durable `step.ai.generate` is one model call as a step: it runs once, and a retry after a crash returns the recorded result instead of paying the model again. The call happens in your runner, with your provider key. ```ts theme={null} export const triage = workflow<{ ticketId: string; subject: string }>({ name: "support.triage", handler: async (ctx) => { const { output } = await ctx.step.ai.generate<{ category: string; priority: number }>("classify", { model: "claude-opus-4-8", prompt: `Classify this ticket: ${ctx.event.data.subject}`, output: { type: "object", properties: { category: { type: "string" }, priority: { type: "number" } }, required: ["category", "priority"], }, validate: (v) => { const p = (v as { priority: number }).priority; return p >= 1 && p <= 5 ? undefined : "priority must be 1-5"; }, }); return await ctx.step.run("route", () => routeTicket(ctx.event.data.ticketId, output!)); }, }); ``` A failed `validate` triggers a durable re-ask, itself a memoized step. [AI](/ai) · [step.ai reference](/reference/sdk/ai-steps) ## Park a run on a human decision `step.approval` suspends the run at its checkpoint, holding no worker, until someone approves or denies. The decision resumes the run from that checkpoint and is memoized, so a replay never re-parks. ```ts theme={null} export const refund = workflow<{ ticketId: string; amount: number }>({ name: "support.refund", handler: async (ctx) => { const decision = await ctx.step.approval<{ ticketId: string; amount: number }>("refund-gate", { tool: "issue-refund", args: ctx.event.data, risk: "high", summary: `Refund ${ctx.event.data.amount} on ${ctx.event.data.ticketId}`, }); if (decision.status === "denied") return { refunded: false, decidedBy: decision.decidedBy }; return await ctx.step.run("issue", () => issueRefund(decision.args)); }, }); ``` The decider may edit the proposed args; `decision.args` are the effective ones. [Approvals](/ai/approvals) ## Log, then watch a run live `ctx.log` writes structured, leveled lines onto the run. `runs.watch` streams the run's timeline - status transitions, step transitions, and log lines - and ends on its own when the run is terminal. ```ts theme={null} import { createClient } from "@duraton/sdk"; const duraton = createClient({ url: process.env.DURATON_URL!, apiKey: process.env.DURATON_API_KEY, }); const { runId } = await duraton.events.send({ name: "ticket.created", app: "support-app", data: { ticketId: "T-421" }, }); if (runId) { for await (const frame of duraton.runs.watch(runId)) { console.log(frame); } } ``` `runId` is absent when the event started no run - it was deduped, dropped, debounced, or batched by a flow-control policy. [Logging](/core/logging) · [Realtime](/core/realtime) ## Replay a finished run A replay forks a **new** run from the original's trigger and links it back through `replayOf`; it does not mutate the original. `retryFromStep` carries the steps before the named one as memoized and resumes there, so completed work is not re-executed. ```ts theme={null} await duraton.runs.replay(runId); // re-execute every step await duraton.runs.replay(runId, { ticketId: "A2" }); // fork with an edited input await duraton.runs.retryFromStep(runId, "refund"); // carry triage + cool-off, resume at refund await duraton.runs.bulkReplay({ app: "support-app", workflow: "ticket.created", status: "failed", since: "2026-07-01T00:00:00Z", }); ``` Every cancel, pause, resume, replay, and retry is recorded with the API key that performed it. [Control API](/reference/api/control) ## Receive and send webhooks `ctx.webhook.send` is a durable outbound delivery: retried on a backoff, with every attempt recorded in the delivery log. ```ts theme={null} export const shipped = workflow<{ ticketId: string; tracking: string }>({ name: "ticket.shipped", handler: async (ctx) => { await ctx.webhook.send("notify-partner", { url: "https://partner.example.com/hooks/shipped", data: ctx.event.data, }); }, }); ``` Inbound is the mirror: register a receiver in the console and a signature-verified `POST` becomes an event that starts a run. [Webhooks](/integrations/webhooks) ## Drive it from anywhere else No SDK required. Every run, event, and control action is an HTTP endpoint your key can call, and the same surface is exposed as [MCP](/integrations/mcp-server) tools for an agent or editor. ```sh theme={null} curl -X POST "$DURATON_URL/events" \ -H "Authorization: Bearer $DURATON_API_KEY" \ -d '{"name":"ticket.created","app":"support-app","data":{"ticketId":"T-421"}}' ``` [REST API](/reference/api) · [MCP](/integrations/mcp-server) # Upgrading Source: https://docs.duraton.ai/start/upgrading Every breaking change to the Duraton SDK, what to rename, and why - newest first. Duraton ships breaking changes without deprecated aliases: the old spelling stops working in the release that introduces the new one. This page is the record of every such change, so an upgrade is a mechanical edit rather than a debugging session. Versions are assigned at publish time, so entries are dated. Check `npm dist-tag ls @duraton/sdk` for what `latest` and `next` currently point at. ## 2026-09-24 - `step.ai.infer`, `runs.explain` and retired values are removed `step.ai.infer` and `runs.explain` had no backend on the hosted service, so both only ever failed. The Kafka and workflow-delete leftovers go with them. Removed from `@duraton/sdk` and `@duraton/sdk/client`: * `ctx.step.ai.infer` and the types `InferOptions`, `InferResult`, `InferMessage` * `client.runs.explain` and the type `ExplainOptions` * `"infer"` from `AI_STEP_KINDS` * `"kafka"` from `EVENT_SOURCES` * `"kafka_source_create"`, `"kafka_source_update"`, `"kafka_source_delete"`, `"kafka_delivery_replay"` and `"workflow_delete"` from `CONTROL_ACTION_KINDS` * `"per-partition"`, `"per-queue"`, `"per-stream"` from `INGRESS_ORDERINGS` * `"positional"`, `"per-message"` from `INGRESS_ACK_MODELS` * `"broker"` from `INGRESS_REDELIVERY_OWNERS` Removed from the API: * `POST /runs/{id}/explain` and the `explain_run` MCP tool * `DELETE /workflows/{app}/{name}` * `POST /data/purge` and `DELETE /data` Self-hosted engines: * the engine refuses to start without `DURATON_OPERATOR_KEY`, and no longer reads `DURATON_API_KEY` as a built-in key: issue project keys instead * the engine refuses to start without `DURATON_DATABASE_URL`; SQLite is gone, Postgres only **What to do:** * make the model call from your runner with `step.ai.generate`, which is durable and metered the same way * read `event.source` and `controlAction.action` as `StoredEventSource` and `StoredControlActionKind`: rows recorded before the removal still carry the old values * a `CONTROL_ACTION_KINDS` filter no longer accepts a removed kind Any import or literal of a removed name fails to compile. ## 2026-09-23 - Kafka sources are removed Duraton no longer consumes Kafka. The engine's Kafka connector is gone, and so is its client surface in `@duraton/sdk/client`: * `client.kafka` - the whole `KafkaApi` namespace * the types `KafkaApi`, `KafkaSource`, `KafkaSourceInput`, `KafkaSourceMapping`, `KafkaSourceDelivery`, `KafkaSourceDeliveryAttempt`, `KafkaSourceDeliveryDetail`, `KafkaSourceDeliveriesPage`, `KafkaDeliveryReplayResult`, `KafkaRecordHeader`, `KafkaDeliveryOutcome`, `KafkaDeliveryLogMode`, `ListKafkaSourceDeliveriesOptions` * the constants `KAFKA_DELIVERY_OUTCOMES` and `KAFKA_DELIVERY_LOG_MODES` **What to do:** send events to Duraton over HTTP instead - an inbound webhook source, or `client.events.send()` from your own consumer process. Any import of the names above fails to compile. **What still works:** events and audit rows recorded before the removal still read back with `"kafka"` and the `kafka_*` values. Those values are no longer part of the typed sets (see 2026-09-24); read them as `StoredEventSource` and `StoredControlActionKind`. ## 2026-09-08 - `defineWorkflow` is now `workflow` The noun did not change, only the prefix. ```ts theme={null} // before import { defineWorkflow } from "@duraton/sdk"; export const ticketCreated = defineWorkflow<{ ticketId: string }>({ name: "ticket.created", handler: async (ctx) => { /* ... */ }, }); ``` ```ts theme={null} // after import { workflow } from "@duraton/sdk"; export const ticketCreated = workflow<{ ticketId: string }>({ name: "ticket.created", handler: async (ctx) => { /* ... */ }, }); ``` **What to do:** replace the import specifier and the call. Nothing else changes - the options object, the type parameter, the returned value and the handler signature are all identical. `defineWorkflow` still works as a deprecated alias of `workflow`, so existing code keeps compiling. Your editor marks it as deprecated; switch when convenient. **Watch for one thing.** If you assigned the result to a variable named `workflow`, that variable now shadows the import inside its own initializer: ```ts theme={null} const workflow = workflow({ ... }); // ReferenceError, and TypeScript ts(2448) ``` Rename the variable. Naming it after what it does reads better anyway: ```ts theme={null} const ticketCreated = workflow({ ... }); ``` **What is unaffected:** `WorkflowDefinition` and `AnyWorkflowDefinition` keep their names, the `workflow` field on events and runs is unchanged, and nothing on the wire or in the API moved. A runner built against the new SDK talks to an unchanged engine. **Why:** every other authoring function in the SDK is already a bare verb or noun - `connect`, `agent`, `tool` - and `defineWorkflow` was the only `define*` symbol left. Across 20 durable-execution and agent frameworks surveyed, none prefixes its authoring function with `define`; Trigger.dev made the same move from `defineJob` to `task()`.