← Writing

Durable agents in TypeScript, Python, and Go - one runtime

Your models are called from Python, your API is TypeScript, and the thing that has to not fall over is Go. Most durable-execution stacks make you pick one. Here is what it looks like when the run is the shared object and the language is just where a step happens to execute.

The ML people write Python. The product surface is TypeScript. The service that must not fall over at 3am is Go.

This is not a failure of engineering discipline. It is what happens when you pick the right tool three times. And then you go to add durable execution to your agent and discover that the runtime you chose has one real SDK and two afterthoughts, so now the language question and the durability question are the same question.

They should not be.

The problem: the run is the thing, not the process

Here is the mental model that makes this simple, and that most stacks quietly violate.

A run is not a process. A run is a record — an ordered journal of steps, each with an id, an input, and a recorded result. Something has to execute the next step, but the identity of the run does not live in whatever executed it. It lives in the journal.

Once you take that seriously, "which language is my agent in" becomes an oddly-shaped question. The journal is JSON. The protocol that hands a step to something and takes back a result is JSON over a socket. Nothing about that is language-flavoured.

So why does it usually feel language-flavoured?

Because most durable-execution SDKs are not clients. They are the runtime, embedded in your process, in one language, with the other languages reimplemented later at varying depth. The Python one is missing flow control. The Go one is missing the AI steps. The TypeScript one is the real one. You do not notice until you need the missing thing.

What the shape actually is

The load-bearing decision is that your code is a runner, and the runner dials out.

Your process connects outward over a WebSocket to the engine. No inbound port, no public URL, no ingress rules — it works from a laptop, a container, or a box behind NAT. The engine says "run step triage of run 01H…", your process runs it, and reports the result back. The engine writes it to the journal.

That is the whole contract. And it means the SDK's job is small and honest: speak the protocol, expose ergonomic step helpers, hand results back. There is nothing in that list that one language can do and another cannot.

The same workflow, three times

Same agent in all three: summarise a refund request with a model, wait an hour, then issue the refund. One model call, one durable sleep that holds no worker, one acting step.

TypeScript

import { connect, defineWorkflow } from "@duraton/sdk";

interface Ticket { ticketId: string; subject: string }

const ticketCreated = defineWorkflow<Ticket>({
  name: "ticket.created",
  retry: { maxAttempts: 3 },
  handler: async (ctx) => {
    const { text } = await ctx.step.ai.generate("triage", {
      model: "claude-opus-4-8",
      prompt: `Summarise this refund request: ${ctx.event.data.subject}`,
    });

    await ctx.step.sleep("cool-off", "1h");

    return ctx.step.run("refund", () => ({ refundId: `re_${ctx.event.data.ticketId}`, text }));
  },
});

connect({ app: "support-app", workflows: [ticketCreated] });

Python

import asyncio

from duraton import GenerateOptions, RetryConfig, Runner, define_workflow
from duraton.context import StepContext


async def handle_ticket(ctx: StepContext) -> object:
    triage = await ctx.step.ai.generate(
        "triage",
        GenerateOptions(
            model="claude-opus-4-8",
            prompt=f"Summarise this refund request: {ctx.event.data['subject']}",
        ),
    )

    await ctx.step.sleep("cool-off", "1h")

    ticket_id = ctx.event.data["ticketId"]
    return await ctx.step.run("refund", lambda: {"refundId": f"re_{ticket_id}", "text": triage.text})


ticket_created = define_workflow(
    "ticket.created",
    handle_ticket,
    retry=RetryConfig(max_attempts=3),
)


async def main() -> None:
    await Runner([ticket_created], app="support-app").run()


asyncio.run(main())

Go

package main

import (
	"log"
	"time"

	"duraton.dev/sdk-go/duraton"
)

type Ticket struct {
	TicketID string `json:"ticketId"`
	Subject  string `json:"subject"`
}

var ticketCreated = duraton.DefineWorkflow(duraton.WorkflowDefinition{
	Name:  "ticket.created",
	Retry: &duraton.RetryConfig{MaxAttempts: 3},
	Handler: func(c *duraton.Context) (any, error) {
		ticket, err := duraton.EventData[Ticket](c)
		if err != nil {
			return nil, err
		}
		triage, err := duraton.Generate(c, "triage", duraton.GenerateOptions{
			Model:  "claude-opus-4-8",
			Prompt: "Summarise this refund request: " + ticket.Subject,
		})
		if err != nil {
			return nil, err
		}
		if err := duraton.Sleep(c, "cool-off", time.Hour); err != nil {
			return nil, err
		}
		return duraton.Run(c, "refund", func() (string, error) {
			return "re_" + ticket.TicketID + ":" + triage.Text, nil
		})
	},
})

func main() {
	runner, err := duraton.Connect(duraton.ConnectOptions{
		App:       "support-app",
		Workflows: []duraton.WorkflowDefinition{ticketCreated},
	})
	if err != nil {
		log.Fatal(err)
	}
	defer runner.Close()
	runner.Wait()
}

Three languages, one runtime. Same step ids, same journal, same run inspector, same replay.

Read the differences, they are the interesting part

The three are not a transliteration, and where they diverge is where each language's conventions won.

Error handling. TypeScript and Python throw; a step that raises retries. Go returns (T, error) and you check it, because pretending otherwise would make it un-Go. This is the most visible difference and it is entirely idiomatic — duraton.Run is generic over the result type precisely so the error path stays explicit.

Options. TypeScript takes an object literal. Python takes a dataclass (GenerateOptions). Go takes a struct with a pointer for optional config (Retry: &duraton.RetryConfig{...}), which is how Go expresses "unset" without an Option type.

Where the type goes. TypeScript puts the event payload type on defineWorkflow<Ticket>. Go uses a generic helper, duraton.EventData[Ticket](c), because the handler signature is fixed. Python reads a dict — the honest option in a language where the payload arrives as JSON and pretending otherwise costs more than it gives.

That last one is worth a footnote, because it was a real bug. Event.data in the Python SDK was originally typed object, which is not indexable, so every documented example failed mypy --strictctx.event.data["ticketId"] simply did not type-check. The fix is a named JSONValue alias resolving to Any. Tempting alternatives that do not work: a recursive JSON union still is not key-indexable (a union containing str and None cannot be subscripted), and a generic Event[T] breaks backward compatibility under disallow_any_generics. Sometimes the honest type is the loose one.

What is identical. Step ids. Retry semantics. The fact that cool-off suspends the run for an hour without holding a worker in any of the three. The journal. The engine cannot tell which SDK produced a step, and that is the point.

The thing this actually buys you

Not "we support three languages." This:

Your model call can live in Python and your API in TypeScript, in one workflow. A run executes in one app, but ctx.step.runWorkflow starts a child run in a different app and awaits its result - so the Python service becomes a durable step of the TypeScript one. The child gets its own journal, its own retries, and its own place in the lineage.

You can move a step between languages without touching the run. Same step id, same recorded result shape, different executor. The journal is unchanged.

The team that owns the reliability-critical path can own it in Go without forcing the ML team out of Python, and without either of them writing the durability layer twice.

What this does not do

It does not make the SDKs identical, and you should not want it to. A Python SDK that returned (result, error) tuples to match Go would be an act of hostility.

It does not mean every feature lands in all three at the same instant. They are separate packages with separate release cadences. Check the reference for the one you are using rather than assuming parity — that is exactly the assumption this article is arguing against making about anyone's SDK, and it would be self-serving to exempt ourselves.

And it does not make cross-language debugging free. When a Go step and a TypeScript step disagree about a field's shape, you are debugging JSON, which is nobody's favourite afternoon. The run journal at least tells you exactly which step produced what.

The question to ask

When you evaluate a durable-execution runtime, do not ask which languages it supports. Ask:

  1. Is the SDK a client of a runtime, or is it the runtime embedded in my process?
  2. Is the wire protocol documented, so I could write the fourth SDK myself?
  3. Can a run in one language await a durable step that executes in another?
  4. When a feature ships, does it ship in the protocol or in one SDK?

The first question predicts the other three. A runtime that lives inside your process has to be rewritten per language, and it will always be one real SDK and some ports. A runtime your code dials into has one implementation, and the SDKs are thin enough to stay honest.

That is how Duraton is built — the wire protocol is documented, and TypeScript, Python, and Go are three clients of it.

Last in a series: Your agent will die halfway, Where does it have to ask?, Checkpointing is not completion, Evals belong in the runtime.