Workflows
Un workflow son datos que guarda el daemon y cuerpos que el daemon nunca ve. Esta página son los datos: qué puede decir una definición, y qué le hace hacer al daemon cada construcción.
El reparto del trabajo
Una ejecución duradera tiene dos dueños. El daemon es dueño de la ejecución entre pasos: el cursor, el diario, el presupuesto de reintentos, los temporizadores y las esperas. Tu proceso es dueño de todo lo que hay dentro de un paso: el modelo al que llamas, el prompt que envías, las herramientas que ejecutas, las credenciales que guardas. Al daemon no se le entrega nada de eso, y nunca ejecuta un modelo en tu nombre.
Así que un workflow llega en dos partes desde un mismo archivo. El grafo va al daemon como una definición, un documento que el daemon recorre. Los cuerpos de los pasos se quedan en tu proceso, en el lenguaje en que los escribiste, para que los sirva un worker. No hay nada que desplegar en el daemon, y un cuerpo puede cambiar sin que la definición cambie en absoluto.
The cursor
The daemon holds the run between steps: where it is, what each step answered, what it is waiting for and until when. A worker holds one step at a time, and only while it runs. During a wait no process is alive at all.
Escribir uno
El constructor produce las dos partes. document() es la
definición, y cada llamada a step o a agent se
queda con su cuerpo para el worker. Cada SDK tiene el mismo constructor con
las mismas nueve construcciones, así que un workflow escrito en un lenguaje
se lee igual en otro, y el daemon almacena el mismo documento lo haya
escrito quien lo haya escrito.
un workflow de soporte
const support = workflow({ id: 'support_ticket' })
.step('classify', async (ctx, input: { ticket: string }) => ({
kind: input.ticket.startsWith('Q') ? 'question' : 'bug',
}))
.agent(
'draft',
async (ctx, input: { kind: string }) => {
// Your own model call, recorded under this step in the run's trace. Its
// usage adds up into the step's, which the daemon prices.
return await ctx.withSpan('model_call', async (span) => {
span.setModel('claude-sonnet-4', 'anthropic')
span.setUsage({ inputTokens: 812, outputTokens: 37 })
return `an answer to a ${input.kind}`
})
},
{ input: { kind: '${steps.classify.output.kind}' } },
)
.waitFor('approval', {
event: 'approved',
key: '${input.ticket}',
timeoutMs: 7 * 24 * 3_600_000,
when: when('steps.classify.output.kind', 'eq', 'bug'),
})
.step(
'send',
async (ctx, input: { text: string }) => {
if (!(await deliver(input.text, ctx.idempotencyKey))) {
throw new RetryableError('mail relay busy', { retryAfterMs: 2_000 })
}
return { sent: true }
},
{
input: { text: '${steps.draft.output}' },
effect: 'at_most_once',
retry: { maxAttempts: 3, backoff: 'exponential', initialMs: 500 },
},
)
.output({ sent: '${steps.send.output.sent}' }) support = workflow("support_ticket")
@support.step("classify")
def classify(ctx, input, prior):
return {"kind": "question" if input["ticket"].startswith("Q") else "bug"}
@support.agent("draft", input={"kind": "${steps.classify.output.kind}"})
def draft(ctx, input, prior):
# Your own model call, recorded under this step in the run's trace. What
# the span spends is the step's usage: the daemon prices it from here.
with ctx.span("model_call", kind="model_generation") as span:
span.set_model("claude-sonnet-4", "anthropic")
span.set_usage(input_tokens=812, output_tokens=37)
text = f"an answer to a {input['kind']}"
return text
support.wait_for("approval", event="approved", key="${input.ticket}",
timeout_ms=7 * 24 * 3_600_000,
when=when("steps.classify.output.kind", "eq", "bug"))
@support.step("send", input={"text": "${steps.draft.output}"}, effect="at_most_once",
retry=RetryPolicy(max_attempts=3, backoff="exponential", initial_ms=500))
def send(ctx, input, prior):
if not deliver(input["text"], ctx.idempotency_key):
raise StepFailure("mail relay busy", class_="retryable", retry_after_ms=2_000)
return {"sent": True}
support.output({"sent": "${steps.send.output.sent}"}) let support = Workflow::new("support_ticket")
.step(
"classify",
|ctx: StepContext| async move {
let input: Value = ctx.input()?;
let ticket = input["ticket"].as_str().unwrap_or("");
let kind = if ticket.starts_with('Q') { "question" } else { "bug" };
Ok::<_, StepFailure>(json!({ "kind": kind }))
},
StepOptions::new(),
)
.agent(
"draft",
|ctx: StepContext| async move {
// Your own model call, recorded under this step in the run's trace.
// Its usage is the step's usage: the daemon prices what the span spent.
let span = ctx.span("model_call", SpanKind::ModelGeneration);
span.set_model("claude-sonnet-4", "anthropic");
span.set_tokens(812, 37);
span.end();
Ok::<_, StepFailure>("an answer")
},
StepOptions::new().input(draft_input),
)
.wait_for(
"approval",
"approved",
"${input.ticket}",
Duration::from_secs(7 * 24 * 3600),
StepOptions::new().when(when("steps.classify.output.kind", "eq", "bug")),
)
.step(
"send",
|ctx: StepContext| async move {
let input: Value = ctx.input()?;
let text = input["text"].as_str().unwrap_or("");
deliver(text, ctx.idempotency_key()).await.map_err(busy)?;
Ok::<_, StepFailure>(json!({ "sent": true }))
},
StepOptions::new()
.input(json!({ "text": "${steps.draft.output}" }))
.at_most_once()
.retry(backoff),
)
.output(json!({ "sent": "${steps.send.output.sent}" })); support := mcpd.NewWorkflow("support_ticket").
Step("classify", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
var in struct {
Ticket string `json:"ticket"`
}
if err := step.Input(&in); err != nil {
return nil, err
}
kind := "bug"
if strings.HasPrefix(in.Ticket, "Q") {
kind = "question"
}
return map[string]any{"kind": kind}, nil
}).
Agent("draft", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
// Your own model call. A span started from ctx lands under this step,
// and what it spent is the step's usage: the daemon prices the sum.
_, span := m.Start(ctx, "model_call", mcpd.WithModel("claude-sonnet-4", "anthropic"))
defer span.End()
span.SetTokens(812, 37)
return "an answer", nil
}, mcpd.Input(map[string]any{"kind": "${steps.classify.output.kind}"})).
WaitFor("approval", "approved", "${input.ticket}", 7*24*time.Hour,
mcpd.Guard(mcpd.When("steps.classify.output.kind", "eq", "bug"))).
Step("send", func(ctx context.Context, step *mcpd.StepContext) (any, error) {
if err := deliver("the draft", step.IdempotencyKey); err != nil {
return nil, mcpd.Retryable(err, 2*time.Second)
}
return map[string]any{"sent": true}, nil
}, mcpd.Input(map[string]any{"text": "${steps.draft.output}"}), mcpd.AtMostOnce(),
mcpd.Retry(mcpd.RetryPolicy{
MaxAttempts: 3, Backoff: mcpd.BackoffExponential, Initial: 500 * time.Millisecond,
})).
Output(map[string]any{"sent": "${steps.send.output.sent}"}) val support = Workflow("support_ticket")
.step("classify") { ctx ->
val ticket = ctx.input<JsonObject>()["ticket"]!!.jsonPrimitive.content
mapOf("kind" to if (ticket.startsWith("Q")) "question" else "bug")
}
.agent("draft", StepOptions(input = mapOf("kind" to "\${steps.classify.output.kind}"))) {
ctx ->
// Your own model call, recorded under this step in the run's trace.
// Its usage is summed into the step, so the daemon prices the run.
ctx.span("model_call", SpanKind.MODEL_GENERATION) { span ->
span.setModel("claude-sonnet-4", "anthropic")
span.setUsage(inputTokens = 812, outputTokens = 37)
}
"an answer to a " + ctx.input<JsonObject>()["kind"]!!.jsonPrimitive.content
}
.waitFor(
"approval", "approved", "\${input.ticket}", 7.days,
StepOptions(guard = predicate("steps.classify.output.kind", "eq", "bug")),
)
.step(
"send",
StepOptions(
input = mapOf("text" to "\${steps.draft.output}"),
effect = Effect.AT_MOST_ONCE,
retry = RetryPolicy(
maxAttempts = 3, backoff = Backoff.EXPONENTIAL, initial = 500.milliseconds,
),
),
) { ctx ->
val text = ctx.input<JsonObject>()["text"]!!.jsonPrimitive.content
if (!deliver(text, ctx.idempotencyKey)) {
throw RetryableException("mail relay busy", retryAfter = 2.seconds)
}
mapOf("sent" to true)
}
.output(mapOf("sent" to "\${steps.send.output.sent}")) support =
Workflow.new("support_ticket")
|> Workflow.step("classify", fn _ctx, input, _prior ->
kind = if String.starts_with?(input["ticket"], "Q"), do: "question", else: "bug"
%{"kind" => kind}
end)
|> Workflow.agent(
"draft",
fn ctx, input, _prior ->
# Your own model call, recorded under this step in the run's trace. Its
# usage is the step's: the daemon prices what the trace shows.
opts = [kind: :model_generation, model: "claude-sonnet-4", provider: "anthropic",
usage: %{"input_tokens" => 812, "output_tokens" => 37}]
StepContext.span(ctx, "model_call", opts, fn -> :ok end)
"an answer to a #{input["kind"]}"
end,
input: %{"kind" => "${steps.classify.output.kind}"}
)
|> Workflow.wait_for("approval",
event: "approved",
key: "${input.ticket}",
timeout_ms: 7 * 24 * 3_600_000,
when: where("steps.classify.output.kind", :eq, "bug")
)
|> Workflow.step(
"send",
fn ctx, input, _prior ->
unless deliver.(input["text"], ctx.idempotency_key) do
raise Mcpd.StepException,
message: "mail relay busy", class: :retryable, retry_after_ms: 2_000
end
%{"sent" => true}
end,
input: %{"text" => "${steps.draft.output}"},
effect: :at_most_once,
retry: [max_attempts: 3, backoff: :exponential, initial_ms: 500]
)
|> Workflow.output(%{"sent" => "${steps.send.output.sent}"})
Tres cosas de ese ejemplo son la forma de todo workflow. La entrada de un
paso es una plantilla que el daemon renderiza en cada intento, así que
draft lee lo que respondió classify sin que
ninguno de los dos cuerpos sepa del otro. Una guarda decide si un nodo
llega a ejecutarse: una pregunta se envía tal cual, un bug espera a una
persona. Y el último paso es at_most_once con una política de
reintentos, que es la combinación que necesita un efecto secundario: el
daemon reintenta un fallo pasajero, y nunca ejecuta un cuerpo dos veces sin
que se lo digan.
Las construcciones
Nueve, y cada SDK tiene las nueve. Dos son reclamables, es decir, las sirve un worker; el resto son del propio daemon.
| Construcción | Quién la ejecuta | Qué hace |
|---|---|---|
task | Un worker | Trabajo reclamable. El worker ejecuta el código que registró con este id. |
agent | Un worker | Lo mismo sobre el cable, registrado como un turno de agente: tu propio bucle, con todas las llamadas al modelo que hagan falta, como un único paso duradero. La traza y la interfaz lo distinguen de un task normal. |
sleep | El daemon | Un retardo de tantos milisegundos. Durante él no hay ningún proceso vivo. |
sleep_until | El daemon | Un retardo hasta un instante renderizado desde una plantilla, por ejemplo una fecha de facturación que viene en la entrada. |
wait_event | El daemon | Una pausa hasta que llega un evento con este nombre y esta clave, o hasta que vence el plazo. La clave se renderiza por ejecución, así que una espera se dirige a un pedido y a ningún otro. |
parallel | Los dos | Ramas con nombre, cada una un paso reclamable, todas en disco antes de que se ofrezca ninguna. La ejecución sigue adelante cuando acaba la última rama. |
foreach | Los dos | Un paso reclamable por cada elemento del array que hay en una ruta, acotado por max_items y, si lo pides, por cuántos corren a la vez. Las salidas vuelven como un array en el orden de los elementos. |
loop | Los dos | Un bucle acotado sobre un paso reclamable. until se evalúa después de cada iteración y puede terminarlo antes. |
subworkflow | El daemon | Una ejecución hija de otra definición, con su propio diario. Su salida pasa a ser la salida de este paso, y cancelar la ejecución madre la cancela. |
Una rama, el cuerpo de un foreach y el cuerpo de un loop son un paso reclamable cada uno. Un cuerpo que necesita varios pasos es un subworkflow, y eso mantiene plano cualquier diario: una ejecución es una lista, nunca un árbol de listas.
Plantillas y guardas
Una plantilla es JSON con referencias $${path} dentro de
sus cadenas. El daemon la renderiza cuando se crea un intento, con lo que
la ejecución tiene en ese momento: input, la entrada de la
ejecución; steps.<id>, lo que respondió un paso
anterior, con .output, .status y
.error; run, el id y la correlación de la propia
ejecución; y, dentro de un foreach o de un loop, item,
index e iter.index. Una referencia que ocupa toda
la cadena conserva su tipo, así que
"$${steps.classify.output}" le entrega al paso siguiente un
objeto, y no una cadena con un objeto dentro.
Una guarda es un predicado sobre esas mismas rutas: eq,
ne, lt, lte, gt,
gte, in, exists y
matches, combinados con all, any y
not. Una guarda que sale falsa escribe el nodo como
skipped y la ejecución sigue. Una ruta que no está hace que
toda comparación sea falsa en lugar de lanzar un error, porque una
ejecución que muere por un campo que falta es una ejecución que nadie puede
terminar.
El daemon valida el documento cuando lo almacena. Una guarda que nombra una
ruta que nada puede producir, una plantilla que lee un paso declarado más
tarde, un foreach sobre algo que no es una ruta, un id que no encaja con
^[a-z][a-z0-9_]*$: cada uno se rechaza con un 400 que nombra
el campo. Los campos desconocidos también se rechazan, así que una guarda
mal escrita es un error, y no una ejecución que se salta su guarda.
Cada duración tiene un tope además de un mínimo.
run_timeout_ms, step_timeout_ms, el
initial_ms y el max_ms de un reintento, el
ms de un sleep y el timeout_ms de una espera
valen como mucho 315360000000, que son 3650 días, y uno más
largo se rechaza con un 400 que nombra el campo, el tope y el valor que
enviaste. Un plazo más lejano que eso no cabría en el reloj con el que el
daemon lo guarda.
Reintentos, efectos y fallos
Un cuerpo que falla le dice al daemon cómo, con una clase.
retryable merece otro intento, y el daemon lo hace mientras
dure el presupuesto retry del nodo, con el backoff del nodo, y
esperando al menos el retry_after_ms que pidió el cuerpo, que
está sujeto al mismo tope de 3650 días que cualquier otra duración.
fatal e invalid_input no se reintentan.
unservable significa que este worker no guarda la definición o
el paso, y el intento vuelve a la cola para otro worker sin gastar
presupuesto. Un cuerpo que lanza cualquier otra cosa es fatal.
max_attempts cuenta todos los intentos, incluido el primero,
así que el valor por defecto de uno significa que no hay reintento. Agotado
el presupuesto, decide on_error: fail_run, el
valor por defecto, termina la ejecución con el mensaje del paso como su
error; continue marca el paso como fallido y sigue adelante,
de modo que una guarda posterior puede leer
steps.<id>.status y esquivarlo.
effect dice lo que costaría duplicar un cuerpo. Bajo
at_least_once, el valor por defecto, un lease que se pierde
vuelve a encolar el intento y el cuerpo puede ejecutarse otra vez. Bajo
at_most_once el worker debe publicar started
antes del cuerpo, y un intento ya iniciado que pierde su lease falla en vez
de volver a ejecutarse. Úsalo para el paso que envía el correo o cobra la
tarjeta, y dale a ese cuerpo el idempotency_key del lease para
que se lo pase al tercero: es estable entre reintentos del mismo paso, así
que un reintento tras una respuesta perdida se reconoce también al otro
lado.
Versiones
Una definición es inmutable una vez almacenada. Publica el mismo cuerpo otra vez y recibes de vuelta la misma versión; publica un cuerpo distinto bajo una versión que ya existe y el daemon lo rechaza con un 409, porque si no una ejecución que fijó esa versión cambiaría por debajo. Omite la versión y el daemon asigna el número siguiente, que es lo que hace un worker al arrancar. Una ejecución fija la versión con la que empezó y no vuelve a resolverla nunca, así que un despliegue que almacena la versión 3 termina las ejecuciones de la versión 2 tal como se escribieron.
Límites
Una definición puede declarar max_steps,
run_timeout_ms y max_cost_usd. El daemon cuenta
cada intento contra el primero, la edad de la ejecución contra el segundo,
y el precio del consumo que informan los pasos contra el tercero, y termina
la ejecución como timed_out o failed en el tope,
con el tope nombrado como el error. Un loop que no converge y un paso de
agente que no deja de gastar son el mismo problema para el daemon, y el
mismo campo detiene los dos.
Qué guarda el daemon
La entrada de una ejecución, su estado, su salida y la entrada y la salida de cada paso se guardan en el almacén de ejecuciones tal como se enviaron. No se ocultan: son el estado de trabajo del propio workflow, un paso posterior los vuelve a leer, y un valor que el ocultador hubiera reescrito cambiaría lo que hace la ejecución. Los spans que una ejecución escribe en el almacén de trazas se ocultan como cualquier otro span. La retención y la supresión llegan también al almacén de ejecuciones, para las ejecuciones que han terminado. Ejecuciones dice exactamente qué borra cada una.
A continuación
- Servir los cuerpos, y el protocolo que habla un worker: Workers.
- Arrancar, observar, señalizar y cancelar una ejecución: Ejecuciones.
- El documento en sí, campo a campo: definition.schema.json, y el contrato vinculante en run-contract-v1.md.