# MAF Workflows — Checkpointing & Human-in-the-Loop

> **Scope:** stacks=["dotnet"]
> **Layer:** 1 (on-keyword)
> **Keywords:** durable workflow, checkpoint, checkpointing, human in the loop, hitl, requestport, requestinfoevent, resume workflow, long running workflow, approval workflow, icheckpointstore, filesystemjsoncheckpointstore, tool approval, ApprovalRequiredAIFunction, ToolApprovalAgent
> **Read by Claude in:** plan (when `ai-agents-workflows` identifies a genuine HITL or long-running need, **or** when a single agent has an irreversible tool) + implement (wiring `RequestPort` / `CheckpointManager` / `ApprovalRequiredAIFunction`)

**Verified against:** Microsoft.Agents.AI 1.20.0 + Microsoft.Extensions.AI 10.9.0 (ai-pin 2026-09-08); `Microsoft.Agents.AI.Workflows` 1.20.0 e `Microsoft.Agents.AI.DurableTask` `1.16.0-preview.260730.1` **não estão no pin** — versões apuradas no nuget.org em 2026-09-08. **Verificação documental**, sem cláusula `provado por`: o ai-kit não exercita workflows nem aprovação de tool. Os nomes de tipo e membros de aprovação foram **medidos por reflexão sobre as assemblies do pin** em 2026-09-08. Last-verified: 2026-09-08.

---

## Quando usar este standard

Read `ai-agents-sweet-spot` and `ai-agents-workflows` first. This standard applies only once `ai-agents-workflows` has justified a Workflow **and** the reason is one of:

- Long-running execution with a human-in-the-loop (HITL) approval step.
- A workflow whose state must survive a process restart (deploy, crash, restart) between steps.

If neither applies, a plain workflow (or no workflow at all — see `ai-agents-sweet-spot`) is enough; do not add checkpointing or `RequestPort` "just in case."

---

## Human-in-the-Loop via `RequestPort`

HITL is modeled with `RequestPort` — a special node in the workflow graph that pauses execution and waits for an external (human) response instead of processing automatically.

```csharp
using Microsoft.Agents.AI.Workflows;

// Request type the port sends out, response type it expects back.
RequestPort<ApprovalRequest, bool> approvalPort =
    RequestPort.Create<ApprovalRequest, bool>("RefundApproval");

var workflow = new WorkflowBuilder(refundAnalyzerAgent)
    .AddEdge(refundAnalyzerAgent, approvalPort)
    .AddEdge(approvalPort, refundProcessorExecutor)
    .WithOutputFrom(refundProcessorExecutor)
    .Build();
```

Running the workflow, the caller watches for `RequestInfoEvent` and sends the human's answer back — the framework routes the response to the executor that made the request:

```csharp
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
    switch (evt)
    {
        case RequestInfoEvent requestEvt:
            // Surface requestEvt.Request to a human (dashboard, email, Slack, ...).
            // This is where execution pauses until an external answer arrives.
            bool approved = await GetHumanDecisionAsync(requestEvt.Request);
            await run.SendResponseAsync(requestEvt.Request.CreateResponse(approved));
            break;

        case WorkflowOutputEvent outputEvt:
            Console.WriteLine($"Workflow completed: {outputEvt.Data}");
            break;
    }
}
```

**Agent tool-approval uses the same mechanism.** When an agent orchestration (sequential, concurrent, group chat) calls a tool marked as requiring approval, the workflow pauses and emits a `RequestInfoEvent` whose payload is a `ToolApprovalRequestContent` instead of a custom request type — no separate API to learn. See `ai-agents-multi-agent-patterns` for agent orchestration patterns this composes with.

---

## Aprovação de tool SEM workflow — o caso do agente único

**A fronteira, primeiro.** Aprovação de tool e `RequestPort` não são o mesmo mecanismo, e usar o segundo onde bastava o primeiro é montar um grafo para pausar uma chamada:

| Situação | Mecanismo |
|---|---|
| **Um agente**, uma tool de efeito irreversível (cobrar, enviar proposta, apagar) | `ApprovalRequiredAIFunction` — **sem workflow nenhum** |
| Um grafo com etapa humana entre executores, ou espera de dias | `RequestPort` + `CheckpointManager` |

O caso do agente único é o que a casa realmente tem (MORPH_OS: billing, envio de proposta) e é o que faltava neste standard.

### O ciclo, medido em `Microsoft.Extensions.AI` 10.9.0

```csharp
// 1. Embrulhe a tool. ApprovalRequiredAIFunction : DelegatingAIFunction.
AIFunction charge = AIFunctionFactory.Create(BillingTools.ChargeCustomer);
AITool guarded = new ApprovalRequiredAIFunction(charge);

AIAgent agent = registry.GetChatClient("text-default")
    .AsAIAgent(instructions: "…", tools: [guarded]);

AgentSession session = await agent.CreateSessionAsync();
AgentResponse response = await agent.RunAsync("Cobre o cliente 42.", session);

// 2. O run TERMINA pedindo aprovação — não bloqueia, não espera.
//    ToolApprovalRequestContent.ToolCall é um ToolCallContent (medido; NÃO é
//    FunctionCallContent — este último aparece em ToolAutoApprovalRuleContext).
var pending = response.Messages
    .SelectMany(m => m.Contents)
    .OfType<ToolApprovalRequestContent>()
    .ToList();

// 3. Responda NA MESMA SESSÃO. CreateResponse(bool approved, string? reason).
if (pending.Count > 0)
{
    var approvals = pending.Select(r => (AIContent)r.CreateResponse(approved: true, reason: "aprovado por lucas@…"));
    response = await agent.RunAsync(new ChatMessage(ChatRole.User, [.. approvals]), session);
}

// 4. REPITA o passo 2 até não restar pendência. Uma volta pode pedir OUTRA tool.
```

**Os quatro pontos que erram na primeira tentativa:**

1. **O run termina, não bloqueia.** Quem espera um `await` que "pausa" escreve um laço que nunca roda.
2. **A resposta vai na MESMA sessão.** Sessão nova = a aprovação não casa com pedido nenhum.
3. **Checar pendência depois de *cada* run**, num laço — não uma vez.
4. **`ToolCall` é `ToolCallContent`.** Tipar como `FunctionCallContent` não compila.

### `ToolApprovalAgent` — quando as regras se repetem

Estável desde **1.14** (2026-07-21). Envolve o agente e centraliza a fila de pedidos e as regras de "sempre aprovar":

```csharp
AIAgent approving = agent.AsBuilder()
    .UseToolApproval(new ToolApprovalAgentOptions
    {
        AutoApprovalRules = [ctx => ctx.FunctionCallContent.Name is "GetInvoice" or "ListPlans"],
        MaxAutoApprovalIterations = 10,
    })
    .Build();
```

`ToolAutoApprovalRuleContext` entrega `Agent`, `Session`, `FunctionCallContent`, `RequestMessages` e `RunOptions` — a regra decide com o contexto inteiro, não só com o nome. `ToolApprovalAgent.AllToolsAutoApprovalRule` é o atalho "aprove tudo" (use em teste, não em produção). Para uma aprovação que vale para as próximas chamadas iguais, `ToolApprovalRequestContentExtensions.CreateAlwaysApproveToolResponse(...)` / `CreateAlwaysApproveToolWithArgumentsResponse(...)`.

### Mudança de 1.14 que QUEBRA código

A partir da 1.14 as respostas de aprovação passaram a ser **vinculadas ao request** — a reconciliação deixou de ser "qualquer resposta serve para qualquer pedido pendente". Código escrito contra a 1.0 precisa ser **revisto**, não apenas recompilado.

O rastro disso está na própria API do pin: `ChatClientAgentOptions` expõe `DisableApprovalResponseBinding` (medido em 1.20.0) — uma flag que só existe porque o *binding* passou a ser o comportamento padrão. Ligá-la é voltar ao comportamento antigo, conscientemente.

> **Nota de migração (histórico, não API viva):** `FunctionApprovalRequestContent` / `FunctionApprovalResponseContent` → `ToolApprovalRequestContent` / `ToolApprovalResponseContent`, renomeados no RC5 (2026-04-01), antes do GA. Os nomes antigos não existem no pin.

### Quando aprovar — e quando aprovação não é a proteção certa

**Peça aprovação quando a tool tem:** efeito colateral externo (cobrança, e-mail, contrato, deploy), dado sensível na saída, efeito **irreversível**, ou escopo amplo (apagar em lote, alterar preço).

**E quando NÃO serve:** no turno de WhatsApp **o lead não é o aprovador**. Mandar um `ToolApprovalRequestContent` para quem está do outro lado da conversa é pedir ao interessado que autorize a si mesmo. Ali a proteção é **guard determinístico no choke-point** — a tool checa o estado do funil, o limite de valor e a permissão do operador **em código**, antes de agir, e recusa com motivo. Aprovação humana é para quando existe um humano com autoridade **diferente** da de quem falou.

---

## Checkpointing — durável sem depender de Azure

Checkpointing is part of the **base, GA** `Microsoft.Agents.AI.Workflows` namespace — no extra/preview package required. A checkpoint captures the full workflow state at each superstep boundary: executor state, pending messages, and pending `RequestPort` requests (so a pending approval survives a restore).

### Three storage options

| Store | Durability | When to use |
|-------|-----------|-------------|
| `CheckpointManager.CreateInMemory()` | In-process only, lost on restart | Tests, demos, short-lived workflows |
| `CheckpointManager.CreateJson(new FileSystemJsonCheckpointStore(dir))` | Local disk, survives process restart | Single-instance VPS deployments — no Azure, no preview package |
| Custom `ICheckpointStore` implementation | Whatever backend you implement it against | **Recommended for production** — implement against the project's own Neon Postgres table, matching the ownership model already used by `ai-agents-rag-custom-pgvector` |

```csharp
using Microsoft.Agents.AI.Workflows;

// Local-disk durability — no Azure dependency:
var checkpointFolder = Directory.CreateDirectory("./checkpoints");
CheckpointManager checkpointManager = CheckpointManager.CreateJson(
    new FileSystemJsonCheckpointStore(checkpointFolder));

StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, input, checkpointManager);
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
    if (evt is SuperStepCompletedEvent stepEvt)
    {
        CheckpointInfo? checkpoint = stepEvt.CompletionInfo?.Checkpoint;
        // Persist checkpoint.CheckpointId alongside the business record
        // (e.g. the refund request row) so it can be resumed after a restart.
    }
}
```

Resuming after a restart — rehydrate into a **new** run instance from the last known checkpoint:

```csharp
StreamingRun resumedRun = await InProcessExecution.ResumeStreamingAsync(
    newWorkflowInstance, savedCheckpoint, checkpointManager);

await foreach (WorkflowEvent evt in resumedRun.WatchStreamAsync())
{
    // A pending RequestPort request is re-emitted here as a RequestInfoEvent —
    // the paused approval survives the restart.
    if (evt is RequestInfoEvent requestEvt) { /* re-surface to the human */ }
}
```

### Custom executor state

An executor with instance state beyond the message flow must opt in explicitly:

```csharp
internal sealed class RefundAnalyzerExecutor() : Executor("RefundAnalyzer")
{
    private const string StateKey = "AnalysisHistory";
    private List<string> _history = [];

    protected override ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken ct = default)
        => context.QueueStateUpdateAsync(StateKey, _history);

    protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken ct = default)
        => _history = await context.ReadStateAsync<List<string>>(StateKey);
}
```

### `ICheckpointStore` — Neon/Postgres-backed (recommended production pattern)

Implementing `ICheckpointStore` against the project's own Postgres database keeps checkpoint durability inside the same infrastructure the rest of the project already trusts (see `infrastructure-neon-setup`), instead of introducing a new storage dependency:

```csharp
public sealed class PostgresCheckpointStore(NpgsqlDataSource dataSource) : ICheckpointStore
{
    // Persist/retrieve checkpoint blobs in a workflow_checkpoints table
    // (workflow_id, checkpoint_id, payload jsonb/bytea, created_at).
    // Implement per the ICheckpointStore contract — see Microsoft.Agents.AI.Workflows docs.
}
```

> **Security:** checkpoint storage is a trust boundary. Never restore a checkpoint from an untrusted or unauthenticated source — a tampered checkpoint can inject arbitrary workflow state.

---

## Azure Durable Task extension — escape hatch, not default

> **Mudança estrutural na 1.17:** a integração Durable Task / Azure Functions foi **extraída para um repositório separado** — deixou de acompanhar o ciclo de release do `Microsoft.Agents.AI`. Isso se vê no próprio versionamento: em 2026-09-08 a última versão de `Microsoft.Agents.AI.DurableTask` no nuget.org é **`1.16.0-preview.260730.1`**, enquanto o pacote base já está em 1.20.0 — quatro minors atrás, e ainda preview. A conclusão desta seção **não muda**: não adotar por padrão. O motivo ganhou um segundo pé: além do acoplamento com Azure, a extensão agora **não é atualizada junto** com o framework.

`Microsoft.Agents.AI.DurableTask` (prerelease) adds durability backed by the **Azure Durable Task Scheduler**, including cost-efficient multi-day HITL waits with zero compute cost while paused. Even its "bring-your-own-compute / self-hosted" mode still requires a Durable Task Scheduler backend (`Microsoft.DurableTask.*.AzureManaged` packages) — self-hosted means your own worker process, not an Azure-free backend.

This framework deploys to VPS/Coolify by default and does not adopt Azure as a default dependency (see `ai-agents-setup` §3.4 on Azure OpenAI). For that reason, `Microsoft.Agents.AI.DurableTask` is **not adopted as default** here. The `CheckpointManager` + custom `ICheckpointStore` pattern above covers the same durability need without an Azure dependency. Only reach for the Durable Task extension when a specific client already runs on Azure and needs multi-day/serverless HITL waits — record that decision in `decisions.md`.

---

## Anti-patterns

| Anti-pattern | Why it's wrong | Right way |
|--------------|----------------|-----------|
| Adding a `Workflow` with `RequestPort` but no `CheckpointManager` | A pending human approval is lost if the process restarts while waiting | Wire a persistent `CheckpointManager` (file-based or custom `ICheckpointStore`) whenever a HITL wait can outlive a single process lifetime |
| Reaching for `Microsoft.Agents.AI.DurableTask` by default | Adds an Azure Durable Task Scheduler dependency this framework's VPS-first stack doesn't otherwise need | Use base `CheckpointManager` + custom `ICheckpointStore`; reserve the Durable Task extension for projects already committed to Azure |
| Storing checkpoints in a public or unauthenticated location | Checkpoint storage is a trust boundary — a tampered checkpoint can inject arbitrary state on restore | Restrict read/write access to the checkpoint store the same way you would a secrets store |
| Skipping `OnCheckpointingAsync`/`OnCheckpointRestoredAsync` on an executor with instance state | State silently resets to defaults after a resume, corrupting workflow logic | Any executor with fields beyond the message flow must implement both methods |
| **Montar um `Workflow` só para pausar uma tool de um agente único** | Um grafo inteiro, com checkpoint store, para o que `ApprovalRequiredAIFunction` resolve em duas linhas | `ApprovalRequiredAIFunction` — §Aprovação de tool SEM workflow |
| **Responder a aprovação numa sessão nova** | A resposta não casa com o pedido; o run recomeça pedindo de novo | Mesma `AgentSession` |
| **Checar `ToolApprovalRequestContent` uma vez só** | Uma volta aprovada pode pedir OUTRA tool; a segunda pendência passa despercebida | Laço: checar depois de **cada** run até zerar |
| **Pedir aprovação ao lead no turno de WhatsApp** | O interessado autorizando a si mesmo não é controle nenhum | Guard determinístico no choke-point da tool |
| Subir de 1.0 para ≥1.14 sem revisar a reconciliação de aprovações | Desde a 1.14 a resposta é **vinculada ao request**; o código antigo casa errado | Revisar; `DisableApprovalResponseBinding` é opt-out consciente, não default |

---

## Checklist (verifiable by morph-eval)

- [ ] `ai-agents-workflows`' HITL or long-running criterion is documented in `decisions.md` before this standard is applied.
- [ ] Every HITL wait point uses `RequestPort` + handles `RequestInfoEvent`.
- [ ] A `CheckpointManager` is wired whenever the workflow can pause across a process restart — `CreateInMemory()` is acceptable only for tests/demos.
- [ ] If a custom `ICheckpointStore` was implemented, it persists to infrastructure already owned by the project (e.g., Neon Postgres), not a new ad hoc store.
- [ ] Executors with instance state implement `OnCheckpointingAsync` and `OnCheckpointRestoredAsync`.
- [ ] `Microsoft.Agents.AI.DurableTask` is present only if the project already runs on Azure and the choice is recorded in `decisions.md` — ciente de que, desde a 1.17, o pacote vive em repositório separado e ficou para trás do framework.
- [ ] Toda tool com efeito irreversível está embrulhada em `ApprovalRequiredAIFunction` **ou** tem um guard determinístico documentado — e a escolha entre os dois está escrita.
- [ ] A resposta de aprovação é enviada na **mesma** `AgentSession`, num laço que checa pendências depois de cada run.
- [ ] Nenhum `Workflow` foi criado apenas para pausar uma tool de um agente único.
- [ ] Nenhuma ocorrência de `FunctionApprovalRequestContent` / `FunctionApprovalResponseContent` (nomes pré-GA).

---

## References

- `ai-agents-workflows` — when a Workflow (and therefore this standard) is justified
- `ai-agents-sweet-spot` — single agent vs workflow decision
- `ai-agents-multi-agent-patterns` — agent orchestrations that share the tool-approval HITL mechanism
- `ai-agents-setup` — the VPS-first, non-Azure-default stance this standard follows for durability
- `infrastructure-neon-setup` — Postgres connection patterns for a custom `ICheckpointStore`
- Human-in-the-loop (HITL) | Microsoft Learn: https://learn.microsoft.com/en-us/agent-framework/workflows/human-in-the-loop
- Checkpoints | Microsoft Learn: https://learn.microsoft.com/en-us/agent-framework/workflows/checkpoints
- Durable Extension | Microsoft Learn: https://learn.microsoft.com/en-us/agent-framework/integrations/durable-extension
- Release notes 1.14 (2026-07-21) e 1.17 do `microsoft/agent-framework` — graduação do `ToolApprovalAgent` e extração do Durable Task (compilados em `docs/specs/elevacao-9/brutos/research-maf.md:312-331`, lidos 2026-09-07)

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/durable-workflows-hitl.md v2.0 (2026-09-08)*
