Workflows
Um workflow são dados que o daemon guarda e corpos que o daemon nunca vê. Esta página são os dados: o que uma definição pode dizer, e o que cada construção faz o daemon fazer.
A divisão do trabalho
Uma execução durável tem dois donos. O daemon é dono da execução entre as etapas: o cursor, o diário, o orçamento de repetições, os temporizadores e as esperas. O seu processo é dono de tudo o que há dentro de uma etapa: o modelo que você chama, o prompt que você manda, as ferramentas que você roda, as credenciais que você guarda. Nada disso é entregue ao daemon, e ele nunca roda um modelo no seu lugar.
Então um workflow chega em duas partes, vindas de um arquivo só. O grafo vai para o daemon como uma definição, um documento que o daemon percorre. Os corpos das etapas ficam no seu processo, na linguagem em que você os escreveu, para um worker servir. Nada precisa ser implantado no daemon, e um corpo pode mudar sem que a definição mude em nada.
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.
Escrever um
O builder produz as duas partes. document() é a definição, e
cada chamada de step ou de agent deixa o corpo
dela para trás, para o worker. Todo SDK tem o mesmo builder com as mesmas
nove construções, então um workflow escrito em uma linguagem se lê igual em
outra, e o daemon grava o mesmo documento seja qual for a que o escreveu.
um workflow de suporte
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}"})
Três coisas naquele exemplo são o formato de todo workflow. A entrada de
uma etapa é um template que o daemon renderiza a cada tentativa, então
draft lê o que classify respondeu sem que nenhum
dos dois corpos saiba do outro. Uma guarda decide se um nó chega a rodar:
uma pergunta segue como está, um bug espera por uma pessoa. E a última
etapa é at_most_once com uma política de repetição, que é a
combinação de que um efeito colateral precisa: o daemon repete uma falha
passageira, e nunca roda um corpo duas vezes sem que lhe digam para isso.
As construções
Nove, e todo SDK tem as nove. Duas são reivindicáveis, o que quer dizer que um worker as serve; o resto é do próprio daemon.
| Construção | Quem roda | O que faz |
|---|---|---|
task | Um worker | Trabalho reivindicável. O worker roda o código que registrou sob este id. |
agent | Um worker | O mesmo no fio, gravado como um turno de agente: o seu próprio laço, com quantas chamadas de modelo forem, como uma etapa durável só. O trace e a interface o distinguem de uma task comum. |
sleep | O daemon | Um atraso de tantos milissegundos. Nenhum processo fica vivo durante ele. |
sleep_until | O daemon | Um atraso até um instante renderizado de um template, como uma data de cobrança que veio na entrada. |
wait_event | O daemon | Uma pausa até chegar um evento com este nome e esta chave, ou até o prazo passar. A chave é renderizada por execução, então uma espera endereça um pedido e nenhum outro. |
parallel | Os dois | Ramos nomeados, cada um uma etapa reivindicável, todos em disco antes de qualquer um ser oferecido. A execução segue adiante quando o último ramo acaba. |
foreach | Os dois | Uma etapa reivindicável por elemento do array que está em um caminho, limitada por max_items e, se você disser, por quantas rodam ao mesmo tempo. As saídas voltam como um array na ordem dos itens. |
loop | Os dois | Um laço limitado sobre uma etapa reivindicável. until é avaliado depois de cada iteração e pode encerrá-lo antes. |
subworkflow | O daemon | Uma execução filha de outra definição, com diário próprio. A saída dela vira a saída desta etapa, e cancelar a execução mãe cancela a filha. |
Um ramo, um corpo de foreach e um corpo de loop são uma etapa reivindicável cada. Um corpo que precisa de várias etapas é um subworkflow, o que mantém todo diário plano: uma execução é uma lista, nunca uma árvore de listas.
Templates e guardas
Um template é JSON com referências $${path} dentro das
strings. O daemon o renderiza quando uma tentativa é criada, a partir do
que a execução guarda naquele momento: input, a entrada da
execução; steps.<id>, o que uma etapa anterior
respondeu, com .output, .status e
.error; run, o id e a correlação da própria
execução; e, dentro de um foreach ou de um loop, item,
index e iter.index. Uma referência que é a string
inteira mantém o tipo, então
"$${steps.classify.output}" entrega à etapa seguinte um
objeto, e não uma string com um objeto dentro.
Uma guarda é um predicado sobre os mesmos caminhos: eq,
ne, lt, lte, gt,
gte, in, exists e
matches, combinados com all, any e
not. Uma guarda que dá falso grava o nó como
skipped e a execução segue adiante. Um caminho que não existe
torna toda comparação falsa em vez de levantar erro, porque uma execução
que morre por um campo que falta é uma execução que ninguém consegue
terminar.
O daemon valida o documento na hora de gravá-lo. Uma guarda que nomeia um
caminho que nada pode produzir, um template que lê uma etapa declarada
depois, um foreach sobre algo que não é um caminho, um id que não casa com
^[a-z][a-z0-9_]*$: cada um é recusado com um 400 que nomeia o
campo. Campos desconhecidos também são recusados, então uma guarda escrita
errado é um erro, e não uma execução que pula a guarda.
Toda duração tem um teto, além de um piso. run_timeout_ms,
step_timeout_ms, o initial_ms e o
max_ms de uma repetição, o ms de um sleep e o
timeout_ms de uma espera valem cada um no máximo
315360000000, que são 3650 dias, e um valor mais longo é
recusado com um 400 que nomeia o campo, o teto e o valor que você mandou.
Um prazo mais distante do que isso não caberia no relógio em que o daemon o
guarda.
Repetições, efeitos e falha
Um corpo que falha diz ao daemon de que jeito, com uma classe.
retryable vale outra tentativa, e o daemon faz uma enquanto
durar o orçamento de retry do nó, no backoff do nó, esperando
pelo menos o retry_after_ms que o corpo pediu, que fica sujeito
ao mesmo teto de 3650 dias que toda outra duração. fatal e
invalid_input não são repetidos.
unservable quer dizer que este worker não guarda a definição
ou a etapa, e a tentativa volta para a fila para outro worker sem gastar o
orçamento. Um corpo que levanta qualquer outra coisa é fatal.
max_attempts conta toda tentativa, inclusive a primeira, então
o padrão de uma quer dizer nenhuma repetição. Esgotado o orçamento, o
on_error decide: fail_run, o padrão, encerra a
execução com a mensagem da etapa como erro dela; continue
marca a etapa como falha e segue adiante, de modo que uma guarda posterior
pode ler steps.<id>.status e desviar dela.
effect diz o que custaria um corpo duplicado. Sob
at_least_once, o padrão, um lease perdido devolve a tentativa
à fila e o corpo pode rodar de novo. Sob at_most_once o worker
precisa mandar started antes do corpo, e uma tentativa já
iniciada que perde o lease falha em vez de rodar outra vez. Use isso na
etapa que manda o e-mail ou cobra o cartão, e dê àquele corpo o
idempotency_key do lease para entregar ao terceiro: ele é
estável entre as repetições da mesma etapa, então uma repetição depois de
uma resposta perdida é reconhecida do outro lado também.
Versões
Uma definição é imutável depois de gravada. Mande o mesmo corpo de novo e você recebe a mesma versão de volta; mande um corpo diferente sob uma versão que já existe e o daemon recusa com um 409, porque senão uma execução que fixou aquela versão mudaria por baixo dela. Deixe a versão de fora e o daemon atribui o número seguinte, que é o que um worker faz ao subir. Uma execução fixa a versão com que começou e nunca a resolve de novo, então uma implantação que grava a versão 3 termina as execuções na versão 2 como elas foram escritas.
Limites
Uma definição pode declarar max_steps,
run_timeout_ms e max_cost_usd. O daemon conta
toda tentativa contra o primeiro, a idade da execução contra o segundo, e o
uso precificado que as etapas relatam contra o terceiro, e encerra a
execução como timed_out ou failed no teto, com o
teto nomeado como o erro. Um loop que não converge e uma etapa de agente
que não para de gastar são o mesmo problema para o daemon, e a mesma opção
detém os dois.
O que o daemon guarda
A entrada de uma execução, o estado dela, a saída dela e a entrada e a saída de cada etapa ficam no armazenamento de execuções exatamente como foram enviados. Eles não são ocultados: são o próprio estado de trabalho do workflow, uma etapa posterior os lê de volta, e um valor que o ocultador tivesse reescrito mudaria o que a execução faz. Os spans que uma execução escreve no armazenamento de traces são ocultados como qualquer outro span. A retenção e o apagamento também alcançam o armazenamento de execuções, para as execuções que terminaram. Execuções diz exatamente o que cada um apaga.
A seguir
- Servir os corpos, e o protocolo que um worker fala: Workers.
- Iniciar, acompanhar, sinalizar e cancelar uma execução: Execuções.
- O documento em si, campo a campo: definition.schema.json, e o contrato que vincula tudo em run-contract-v1.md.