Skip to content
Tracon

Workflows

A workflow runs several agents together. Where a callable agent is one agent using another as a tool, a workflow is an orchestration you define and can watch.

Workflows need Tracon.Workflows. Without the engine registered, definition management still works and only the execution endpoints answer 501 — the package stays optional on purpose.

Kind What it does
Sequential Agents run in order; each output is the next input
Concurrent Agents run at the same time; results are merged
Handoff One agent starts and hands off when needed — the model decides
GroupChat A manager distributes turns among participants
Magentic A manager plans, tracks progress, and replans; a manager agent is required

MaxIterations bounds the turn count for Handoff, GroupChat, and Magentic — the only structural guard against two agents handing off to each other forever. Each participant call also carries the same two-layer wait limit a callable agent’s sub-call does (see Agents calling agents): a cooperative deadline, then a hard cutoff for a participant that ignores it. There is no per-participant override here — the installation-wide default applies.

new WorkflowDefinition
{
Name = "triage",
Kind = WorkflowKind.Handoff,
AgentNames = ["frontline", "billing", "technical"],
}

Which fields are required depends on the kind, and the definition is validated when it is saved using the same rules the compiler applies. A shape that could not run is rejected at write time rather than on the first execution.

A real pipeline has steps that are not AI calls — a file download, a format conversion, a database write. AddWorkflowFunction registers one by name:

tracon.AddWorkflowFunction<List<ChatMessage>, List<ChatMessage>>(
"word-count",
services => (messages, context, cancellationToken) =>
{
var text = messages[^1].Text;
return ValueTask.FromResult<List<ChatMessage>>([new(ChatRole.User, $"{text}\n\n({text.Split(' ').Length} words)")]);
},
"Appends a word count. Runs no model call.");

A Sequential definition’s Nodes list can then mix that name in with catalog agents, in order:

new WorkflowDefinition
{
Name = "summarize-and-count",
Kind = WorkflowKind.Sequential,
Nodes =
[
new WorkflowNodeReference { Name = "summarizer", Kind = WorkflowNodeKind.Agent },
new WorkflowNodeReference { Name = "word-count", Kind = WorkflowNodeKind.Function },
],
}

Nodes and AgentNames are mutually exclusive — a definition sets one or the other. Only Sequential supports function nodes: the ready-made builders for the other four patterns accept only agents.

The factory passed to AddWorkflowFunction runs once, when the function registry is built — not once per run. Every workflow compile shares the same handler closure, so the handler must be thread-safe: a captured counter or non-thread-safe client needs its own guard.

A function node opens no runs row of its own and contributes nothing to cost or token totals — it made no model call. ExecutorInvoked / ExecutorCompleted / ExecutorFailed still fire for it, same as any node, so it is visible in the run’s event stream and colored live in the graph.

AddWorkflowFunction takes an optional retry policy:

tracon.AddWorkflowFunction<List<ChatMessage>, List<ChatMessage>>(
"call-shipping-api",
services => (messages, context, cancellationToken) => CallShippingApiAsync(messages, cancellationToken),
"Looks up a shipment.",
retryPolicy: new WorkflowNodeRetryPolicy
{
MaxAttempts = 3,
InitialDelay = TimeSpan.FromSeconds(1),
BackoffMultiplier = 2.0,
});

Only a transient failure is retried — a provider error, an open circuit breaker, a rate limit, or a timeout. Anything else (a bad argument, a permanent downstream error) is thrown on the first attempt, exactly as without a policy. The retry loop runs entirely inside the node’s own call: Microsoft Agent Framework invokes the node once per routed message either way, so retrying costs nothing against TraconWorkflowOptions.MaxSuperSteps — a node that succeeds on its third attempt still counts as exactly one super-step.

Workflows can also be built in code with MAF’s own builder.

Terminal window
curl -N -X POST http://localhost:5081/tracon/api/workflows/triage/run \
-H 'Content-Type: application/json' \
-d '{"message":"My invoice is wrong and the app crashes"}'

Events stream over SSE. The first frame reports the run id, and every agent invoked inside the workflow opens its own run row — so the whole thing is readable as a tree with GET /api/runs/{runId}/tree, with each agent’s tokens and duration attributed separately.

GET /api/workflows/{name}/graph returns the compiled graph. Node ids are identical to the executor ids in the run events, which is how the console colours nodes live as the workflow progresses. The response also carries MAF’s generated Mermaid text.

A workflow run’s error follows the same redaction rule as an agent run: a node failure from your own code keeps its message, a failure from a provider, library, or transport carries only its exception type and a correlation id.

A workflow run ends Canceled only when the run was actually cancelled — the caller’s request going away, or the workflow’s own MaxDuration elapsing. A node whose model or tool call never came back ends the run Failed instead, even though .NET surfaces an HttpClient timeout as a TaskCanceledException. Alert on the two separately: one is a person stopping, the other is an outage.

A workflow’s root run shares the same call-tree budget every agent run does (AgentGraph.MaxTotalTokens/MaxTotalCost/MaxDuration — see Reliable runs): every agent node the workflow invokes spends against the same tree, so a workflow with a long chain of agent steps is bounded the same way a single agent’s own tool loop is.

A workflow writes checkpoints as it goes, controlled by Tracon:Workflows:EnableCheckpointing (default true). GET /api/workflows/runs/{runId}/checkpoints lists them and POST /api/workflows/runs/{runId}/resume continues from one — omit the id to resume from the latest.

Resuming opens a new run. The original is never rewritten, so “what happened, then what we did about it” stays two readable records rather than one edited one.

Checkpoints survive a process restart only with a SQL provider registered (UsePostgreSql(), UseSqlServer(), or UseSqlite()). The in-memory store keeps at most 50 checkpoints per session and drops the oldest — enough for local development, not for a workflow you expect to resume after a restart. They are also a retention target, so an old run may have none left even on durable storage.

Turning off EnableCheckpointing does not silently disable resumption: a workflow that stops to wait for a human answer fails outright instead of hanging with no way to resume.

A checkpoint’s state is the Microsoft Agent Framework’s own serialized graph; Tracon does not interpret it and makes no promise that a checkpoint written by one Microsoft Agent Framework version can be resumed by a different one. See Versions and upgrades for the compatibility policy.

tracon state-check counts stored checkpoints by the Tracon envelope generation stamped on them and says which of those the build running the command understands. That is as far as it can go for a checkpoint: the state inside has no decoder outside a running workflow, so the command reports a sampled checkpoint as checked for structure only and never claims to have resumed it.

A workflow can stop and wait for input:

flowchart LR
    accTitle: Durable workflow input cycle
    accDescr: A workflow request closes the run as awaiting input, writes a checkpoint, then resumes from that checkpoint in a new run after a response.
    RUN["run"] --> ASK["executor raises a request"]
    ASK --> WAIT["run closes as AwaitingInput<br/>a checkpoint is written"]
    WAIT --> LIST["GET .../requests"]
    LIST --> RESP["POST .../respond"]
    RESP --> NEW["a NEW run resumes from the checkpoint<br/>events stream over SSE"]

Requests are read from the run’s own event stream — there is no separate table — and only a run in AwaitingInput has any. The answer is matched to the request that is re-published with the same id when execution resumes.

This is the same append-only shape as tool approvals: the waiting run stays as it was, and the response opens a new one.