Skip to content
Tracon

A workflow that a person can send back

How do you build a workflow where an agent writes, code checks the draft, a person approves it or sends it back with a note, and costly steps run only after the approval? This page builds that graph in code, on the sample’s review-and-revise workflow, and says what each part gives you.

A stored workflow chain cannot do this. It is a straight line of agents and functions, with no way back and no human gate. A graph built in code can, and two helpers give its steps what a stored chain’s steps have: GetWorkflowAgentStep for the writer and GetWorkflowFunction for every function you registered.

flowchart TD
    accTitle: A review and revise workflow built in code
    accDescr: A writer agent step feeds a validation function. A draft that fails validation returns to the writer. A valid draft goes to a person, who approves it or rejects it with a note. A rejection returns to the writer until the revision limit fails the run. An approval runs three media functions in order.
    IN["Run with productionId and language"] --> W["writer<br/>agent step"]
    W --> V["validate<br/>function"]
    V -->|"violation"| R1["revise: count and limit"]
    V -->|"valid"| A{"review<br/>a person answers"}
    A -->|"reject with a note"| R1
    R1 -->|"under the limit"| W
    R1 -->|"limit reached"| F["Run fails: content_rejected"]
    A -->|"approve"| T["tts · render · qa<br/>functions"]
    T --> OUT["Output; run.completed"]

The agent and the functions are registered the usual way. The graph itself is a registration too, and it carries the workflow-level settings a stored definition would:

tracon.AddAgent(new AgentDefinition
{
Name = "episode-writer",
Instructions = "Write a short script in {{language}}. Answer ONLY with JSON: {\"title\": string, \"script\": string}.",
Parameters = [new AgentParameter { Name = "language", Kind = AgentParameterKind.Text, DefaultValue = "en" }],
Model = new ModelBinding
{
Provider = "openai",
Model = "gpt-5.4-mini",
ResponseFormat = new AgentResponseFormat { Kind = AgentResponseFormatKind.JsonSchema, SchemaName = "episode_script", Schema = schema },
},
});
tracon.AddWorkflowFunction<List<ChatMessage>, ScriptCheck>("episode-validate", ValidateFactory);
tracon.AddWorkflowFunction<List<ChatMessage>, List<ChatMessage>, SpeakSettings>(
"episode-tts",
TtsFactory,
options =>
{
options.Timeout = TimeSpan.FromMinutes(2);
options.MaxConcurrency = 1; // one voice synthesis at a time in this process
});
tracon.AddWorkflow(
"review-and-revise",
services => Build(services),
options =>
{
options.ResponseMode = WorkflowResponseMode.SingleAnswer; // a double click follows the first answer
options.RequestTimeout = TimeSpan.FromDays(2); // a review nobody answers fails the run
options.RunTimeout = TimeSpan.FromMinutes(5); // one run, not the wait for a person
options.Parameters =
[
new AgentParameter { Name = "productionId", Kind = AgentParameterKind.Text, Required = true },
new AgentParameter { Name = "language", Kind = AgentParameterKind.Text, DefaultValue = "en" },
];
});

The graph is built again for every run, so the factory must be cheap and must not keep state.

static Workflow Build(IServiceProvider services)
{
var writer = services.GetWorkflowAgentStep("review-and-revise", "episode-writer", step => step.Timeout = TimeSpan.FromMinutes(3));
var validate = services.GetWorkflowFunction("episode-validate");
var ask = Step<ScriptCheck, string>("ask", (check, _, _) => new ValueTask<string>(check.Text));
var review = RequestPort.Create<string, EpisodeReview>("episode-review").BindAsExecutor(allowWrappedRequests: false);
var reviseAfterViolation = Step<ScriptCheck, List<ChatMessage>>("revise-after-violation", (c, ctx, ct) => ReviseAsync(c.Violation!, ctx, ct));
var reviseAfterRejection = Step<EpisodeReview, List<ChatMessage>>("revise-after-rejection", (d, ctx, ct) => ReviseAsync(d.Note ?? "", ctx, ct));
var approved = Step<EpisodeReview, List<ChatMessage>>("approved", async (_, ctx, ct) => [new(ChatRole.User, await ctx.ReadStateAsync<string>("draft", "review", ct) ?? "")]);
var tts = services.GetWorkflowFunction("episode-tts", step => step.SetSettings(new SpeakSettings { Voice = "alloy" }));
var render = services.GetWorkflowFunction("episode-render");
var qa = services.GetWorkflowFunction("episode-qa");
return new WorkflowBuilder(writer)
.AddEdge(writer, validate)
.AddEdge<ScriptCheck>(validate, reviseAfterViolation, check => check?.Violation is not null)
.AddEdge<ScriptCheck>(validate, ask, check => check is not null && check.Violation is null)
.AddEdge(reviseAfterViolation, writer)
.AddEdge(ask, review)
.AddEdge<EpisodeReview>(review, reviseAfterRejection, decision => decision is { Approved: false })
.AddEdge<EpisodeReview>(review, approved, decision => decision is { Approved: true })
.AddEdge(reviseAfterRejection, writer)
.AddEdge(approved, tts).AddEdge(tts, render).AddEdge(render, qa)
.WithOutputFrom(qa)
.WithName("review-and-revise")
.Build();
}

Step is a two-line helper over new FunctionExecutor<TIn, TOut>(id, handler).BindExecutor(). ReviseAsync reads and writes the counter described below.

  • The writer is an agent step, not an agent binding. An agent bound with GetWorkflowAgent and an AIAgentBinding runs only when the framework sends it a turn token, and nothing sends one to a node that is not the graph’s entry. A message that returns to it over a back edge is accepted and nothing happens: the run ends with no output and no error. GetWorkflowAgentStep returns a plain function executor that runs whenever a message arrives. The price is that it streams no token deltas into the workflow’s event feed and keeps no framework session, so a revision carries the previous draft and the note in its input.
  • The revision counter lives in the workflow’s state. Every answer is a new run and the graph is built again, so a counter kept in a field of an executor starts from zero each time and the loop never ends. QueueStateUpdateAsync and ReadStateAsync keep it in the checkpoint, and the run that answers reads it back. An update is visible to the executor that wrote it at once and to the others from the next super-step on: the step that counts also reads the counter, and the approval reads the draft a super-step after the validation wrote it.
  • The request is a string. The console shows a request’s text. A record shows its type name, and the answer is read into EpisodeReview from the data field of the respond call.

GetWorkflowFunction takes a WorkflowFunctionStepOptions (Timeout, Settings, and SetSettings(value) for a typed value). GetWorkflowAgentStep takes a WorkflowAgentStepOptions (Timeout, AgentVersion).

Step Time limit Retry Budget gate Cost named by node Settings Version pin Concurrency
GetWorkflowFunction the registration’s, or the step’s own the registration’s yes yes strict read, typed or JSON no the registration’s
GetWorkflowAgentStep the step’s own, else ChildDeadline no yes: a refused step fails the agent’s own run rows no yes, AgentVersion no

A pinned version that does not exist, or an agent defined in code that keeps no versions, fails the step by name before its model call. A graph factory is synchronous, so it cannot check this when the graph is built, as a stored chain does.

The agent step asks the agent for its whole answer. A JSON answer that does not fit the schema is repaired as it is for a plain agent run (Tracon:StructuredResponse), and a loop agent hands the next node its final iteration only.

  • A revision limit and a cost ceiling. Each answer is a new run with a new budget, so MaxTotalCost does not add up across rounds. Bound the rounds with your own counter, as here, and set the ceiling for one round.
  • MaxConcurrency is per process and per function. It holds across workflows and tenants, in a stored chain and in a graph built in code, and it multiplies with the number of replicas. A call that finds every slot taken waits. The wait is not part of the node’s own Timeout, and it ends when the run is cancelled or reaches its RunTimeout. Each attempt of a retried node takes a slot of its own and gives it back before the retry’s delay, and the budget gate is read after the slot is taken, so a budget that runs out during the wait refuses the node when its turn comes. A handler that ignores its token after a timeout keeps running without a slot, so pass the token on. A value below 1 throws at registration.
  • RunTimeout is held to the host’s ceiling. A value above Tracon:Workflows:MaxRunTimeout (or above RunTimeout while no ceiling is set) stops the host at startup and names the workflow.
  • A request nobody answers fails the run with WorkflowRequestExpired and publishes run.failed to your webhooks, so a pipeline driven by webhooks hears that the review ended.
Terminal window
curl -sS -X POST http://localhost:5081/tracon/api/workflows/review-and-revise/run \
-H 'Prefer: respond-async' -H 'Content-Type: application/json' \
-d '{"message":"start","parameters":{"productionId":"p-1","language":"en"}}'
curl -sS "http://localhost:5081/tracon/api/workflows/requests?state=pending"
curl -sS -X POST http://localhost:5081/tracon/api/workflows/runs/{runId}/respond \
-H 'Prefer: respond-async' -H 'Content-Type: application/json' \
-d '{"requestId":"{requestId}","data":{"approved":false,"note":"Make it shorter."}}'

The answer returns the run that continues. List the pending requests again for the next draft, and answer with "approved": true to run the media functions. GET /api/runs/{runId}/usage lists what each media function reported, by node name.

A queued run that the process left half done continues from its latest checkpoint with ResumeInterruptedRuns on, as a new run linked by continuedFromRunId. The finished steps do not run again, and an approval already given is not asked for again. The run parameters are the recorded ones.

A run that waits for a person was written against the graph it ran. If you change the graph’s structure (add, remove or rename a node) while a run waits, that run cannot continue under the new graph: the framework refuses its checkpoint. Changing a handler’s code, a node’s settings or a time limit does not. Let waiting runs finish or expire before a structural change.