← Writing

Your agent will die halfway. Restarting it is the easy part.

An AI agent is a long job that holds state and spends money. Two things break it: dying mid-run, and coming back on a different machine than the one holding its context. We built the runtime for that, and proved it on something less forgiving first - our own deploy pipeline.

An AI agent is a long job. It calls a model, calls a tool, calls the model again, waits on something slow, and eventually finishes. Three things go wrong in that loop, and only one of them gets talked about.

  1. The process dies halfway. A deploy, an OOM, a rolling restart. The job starts over.
  2. It comes back on a different machine. The instance holding the conversation, the loaded model, or the GPU is not the instance that picks the job up.
  3. It spends money and nothing can stop it mid-run. A dashboard can tell you afterwards. That is not the same thing.

This article is mostly about the first two, and about why solving them properly is what makes the third solvable at all. The third has its own article: Your agent will do something expensive. Where does it have to ask?

We did not start by building this for AI agents. We built it for our own deploy pipeline, which is less forgiving and easier to reason about, and then found that agents need the same two properties for the same reasons. So this is the durability story told through the system that proved it: Duraton.

The worked example: a deploy is a long job too

We run a deployment platform. Provisioning a server or deploying an app is not one call. It is a dozen steps in a row: validate the config, create DNS, stash the files, tell a machine to pull and start the containers, wait for it, check TLS, mark it running, send the email.

Here is the real shape of ours, trimmed:

validate-template
create-dns
stash-files
dispatch-agent-deploy      -> tell the server to pull + start
wait-agent-deploy          -> wait for it to finish
verify-tls
mark-running

If the process running this restarts at verify-tls, you do not want it to start over. Re-creating DNS that already exists, re-dispatching a deploy that already ran: that is how you get duplicates and corrupt state. You want it to pick up at verify-tls and move on.

So we made each step durable. A step runs once, its result is recorded under its id, and if the process dies the job resumes at the next unfinished step. You write the steps. The engine remembers which ones are done.

[Diagram: the deploy job as a row of steps. The process restarts after dispatch-deploy; on restart the job resumes at wait-deploy instead of re-running create-dns.] (asset ready: img/deploy-flow.png)

In code, a step is a named piece of work. The dispatch and the wait collapse into one call here, because invoking another workflow as a child run already waits for its result:

const deploy = defineWorkflow<{ deploymentId: string; nodeId: string }>({
  name: "deploy.app",
  handler: async (ctx) => {
    await ctx.step.run("create-dns", () => createDns(ctx.event.data));
    await ctx.step.run("stash-files", () => stashFiles(ctx.event.data));

    // hand the work to the agent on this exact server, then wait for it
    await ctx.step.runWorkflow("dispatch-deploy", {
      name: "agent.deploy",
      app: "agent",
      runner: ctx.event.data.nodeId,
      data: ctx.event.data,
    });

    await ctx.step.run("verify-tls", () => verifyTls(ctx.event.data));
    await ctx.step.run("mark-running", () => markRunning(ctx.event.data));
  },
});

That runner: nodeId line is the second problem.

The same agent runs on every server. Each one still needs a name.

Our deploy agent is a single binary. The same build runs on every server we manage. In that sense the "app" is one thing, but it is running on a hundred machines at once.

When a deploy says "pull and start the containers", that work cannot go to any agent. It has to go to the agent on the server that owns this app. Provisioning is the same: "create the instance" has to run on the node we picked, not a random one.

Most workflow engines do not do this. They hand a job to whatever worker is free. That is the right default for stateless replicas, and it is wrong here. We needed to send a job to one named machine.

So every runner gets a stable identity that survives restarts, and jobs route by it. The model splits in two:

  • An app is the code.
  • A runner is one running copy of it (the agent on node-42).

One app, many runners. By default a job goes to any runner of the app. Name a runner and it goes only there:

curl -X POST "$DURATON_URL/events" \
  -H "Authorization: Bearer $DURATON_API_KEY" \
  -d '{"name":"agent.deploy","app":"agent","runner":"node-42","data":{}}'

[Diagram: app "agent" with runners node-1 ... node-N. A normal run goes to any runner; a run with runner "node-42" goes only to node-42.] (asset ready: img/apps-runners.png)

We did not get this split right the first time. Our first version made every server its own app. It looked clean until we did the math: a thousand servers running the same code would be a thousand identical apps. Splitting it, so that app is the code and runner is the copy, turned that into one app with a thousand named runners, and everything downstream got simpler.

A runner does not need an inbound URL either. It can dial the engine over an outbound WebSocket, which is what you want for a process sitting on someone else's network behind a firewall. There is no port to open.

[Screenshot: the Duraton console showing a run and its steps, and which runner it ran on.] (asset ready: img/dashboard-runs.png / img/step-flow.png)

Why an AI agent is the same problem

Point that at an agent and nothing about the shape changes.

A long agent run is a row of steps: retrieve, call the model, call a tool, call the model again, write the result. When the process dies at step nine, you want step ten, not step one. Re-running step one means paying for the same model call twice and re-firing whatever side effects it had. Durable steps are how you stop paying twice for work that already succeeded.

And an agent that holds something is our deploy agent again. A conversation in memory, a warmed model, a GPU, a browser session: that state lives on one instance. Handing the next turn to a fresh instance means rebuilding it or losing it. That is the runner: node-42 case, with a different noun.

We built the pinning because our deploys needed it. It turns out to be the same thing an agent with a loaded context needs, which is the main reason we care about getting it right.

What changes once the runtime owns the pause

Here is the part that only works because the engine executes the run rather than watching it.

A run that is suspended costs nothing and holds no worker. Once you have that, a few things stop being hard:

  • A hard ceiling on one run. Declare a cap and the run halts before the step.ai call that would cross it. That call never happens. The run then fails with a BudgetError, and everything committed before the halt stays committed. No person is involved. This is a guardrail, not a question.
  • A ceiling on a whole workflow over a window. Declare a budget and when the window's spend crosses the line, in-flight runs pause at their checkpoint, holding no runner, and resume on their own when the window rolls forward or you raise the budget. Also no person involved.
  • A step that routes to an actual human. ctx.step.approval parks the run in needs_attention. It keeps its checkpoint and holds no runner while it waits. Approve, deny, or edit the arguments and approve, and the run resumes at exactly that step with the effective arguments. A timeout marks the approval overdue and escalates it; it never decides it. A run resumes only on a real decision, however late.

Those are three different mechanisms and it is worth not blurring them. Two are automatic and one asks a person. A tool that sits outside the run can alert you in all three cases. It cannot stop the run in any of them.

For the record afterwards: every step is recorded under its id with its result, and every model call records the model, its token counts, and its latency. Your prompt, the response text, and your provider key stay in your runner; the model call happens there, not here. Separately, every control action (cancel, pause, resume, replay, approve, deny) is written to an audit log with the name of the API key behind it. Two honest limits on that log: it covers control actions, not everything the agent did, and the write is best effort, so a failed audit write never fails the action it describes.

You do not have to rewrite your agent to get any of this. Duraton is framework-neutral and sits underneath whatever you already use for planning, tools, and memory. ctx.step.ai.wrap makes a model call you already wrote durable with no other change to the call site:

const completion = await ctx.step.ai.wrap("classify", () =>
  openai.chat.completions.create({
    model: "gpt-4o",
    messages: [{ role: "user", content: subject }],
  }),
);

Where it honestly is

Duraton is in beta.

Working today: durable steps and retries, scheduled runs, run controls (cancel, pause, resume, replay, retry from a named step), live run streaming, tracing and metrics, both "any runner" and "pin to a named runner", and SDKs for TypeScript, Python, and Go. On the AI side: durable model calls, resumable streaming, cap, budget, token throttling, an inference cache, fallback chains, human approvals, and evals (inline scores, deferred graders, fork-compare, dataset runs).

Since the first draft of this article, one gap closed that is worth naming: a run pinned to a runner that is briefly offline used to fail fast. It now parks and waits for that runner to come back, up to a bounded five minutes measured from a durable stamp, then fails terminally.

Things worth knowing before you believe a benchmark of your own:

  • There is no framework integration package. No plugin for any agent framework exists today. The seam ships (ctx.step.ai.wrap), the package does not. "Framework-neutral" is a real property; "supported integration" would be a lie.
  • cap's cost axis needs a price list you supply. maxTokens always bites, because tokens are metered from every model call. maxCost only bites when your runner prices its own calls, because Duraton holds no price list.
  • The inference cache is narrower than it sounds. It serves an identical deterministic call at zero spend. Caching engages only when temperature is explicitly set to 0.2 or lower. Leave temperature unset and cache: true is a documented no-op.
  • The audit log is control actions only, and best effort. Said above; repeating it because governance copy usually overstates exactly this.
  • It is beta. Install from the @next tag. The wire format can still change.

Try it

Sign up at app.duraton.dev, issue a secret key, and connect a runner:

npm install @duraton/sdk@next
import { connect } from "@duraton/sdk";

connect({ app: "order-app", workflows: [orderCreated] });

Then do the thing that makes durable execution click. Trigger a run, stop your runner with Ctrl-C while a step is sleeping, and start it again. The completed steps do not run a second time. The run picks up at the next one.

The docs are at docs.duraton.dev. If you have routed work to specific machines before, whether deploy agents, GPU boxes, or stateful workers, I would like to hear how you handled the instance going away mid-job. That is the part we are still sharpening.

NextYour agent will do something expensive. Where does it have to ask?