Summary#
An agent that answers a question in one turn needs nothing around it. An agent that does work — reads several things, calls tools, pauses for a person, resumes hours later — needs everything a workflow already provides. So the work runs inside one, and you say which:
app.Agent = new AgentConfig { Loop = ProcessTask }; // the app defaultThe workflow wraps the turn loop; it does not become it. One step of it means "run the agent until it finishes or asks for help".
Signature#
app.Agent = new AgentConfig {
Loop = <workflow> // the default loop for every agent in this app
};
agent Escalator {
Loop = <workflow>; // …overridden for this one
}Both take a bare workflow NAME. Omit Loop on an agent and it takes the app default; omit app.Agent too and the
agent's work runs with no workflow around it — which is the right answer for an app whose agents only ever answer in
the turn.
Description#
The workflow supplies the durability, the agent supplies the turns#
This split is the whole design. The workflow owns:
- durability — the run survives a restart, a deploy, and hours of waiting;
- the human slot — when the agent asks a person something, the wait is a modelled fact with an assignee, an opened-at and an SLA clock, not a paused process;
- escalation and retries — declared, not hand-written;
- the trace — what happened, in order, queryable afterwards.
The agent owns the turns: call the model, dispatch tools, repeat. That loop stays in one place. If a workflow could express it too there would be two implementations of the same thing, and they would drift.
The loop's subject is the TASK#
A loop workflow runs over the agent's task — the same row the work log is built from — and it declares so:
workflow ProcessTask {
Tracks = AgentTask.Status;
Initial = Running;
state Running { }
state Waiting { } // parked on a person
terminal success Completed { }
terminal error Failed { }
}Two things follow, and both are the reason for the choice:
- "What has this agent done, or is waiting for" stays one column to list on. The task's
Statusis the workflow's state. A loop with a state of its own would make that two questions, and a list view would have to know which of them to ask. - The state transition log is the task's. Every move the loop makes is recorded against the piece of work it is about, so the history is where you would look for it.
⚠ While a loop drives a task, the workflow OWNS Status and nothing else writes it. The agent's turn loop
finishing is not the task finishing — a run parked on a person is still parked, and the task goes on reading
Waiting until the workflow moves it. What the platform still records directly is what that run produced: its
outcome and when it stopped.
What a loop must at least have#
The platform does not supply the loop. It checks that yours can do the two things a loop is for, and refuses at compile time when it cannot:
| the rule | why it is a rule |
|---|---|
Tracks = AgentTask.Status | a loop runs over the agent's own task; tracking something else leaves the task's status written by nothing |
a state for every member of AgentTaskStatus | a missing member is a state the task can reach and the workflow has nowhere to put |
Waiting is the one that matters. Without it an agent that asks a person has nowhere to park — and that failure is
an absence: the work runs, nobody is asked, and there is no exception to catch and no log line to find. Said at
compile time it is one sentence naming what to add.
Everything else is yours. Who gets notified when the run parks, what the SLA is, how it escalates, how many times it
retries — the platform has no opinion, because any opinion it had would be wrong in the apps that disagreed. That is
what the Waiting state's enter body is for.
⚑ This is also why samples are safe to copy. Start from one, change it into what your app needs — and if the protocol ever demands more, your copy stops building with a sentence naming what to add, rather than quietly going on doing less than it used to.
Not every task gets one#
- Work a workflow already drives needs no second loop — the task points at the run that is already going, and starting a second would put two state machines on one piece of work.
- Work started by a chat message, an event or a schedule runs in the declared loop, when the work warrants it.
Opening the task starts the run, bound to that task — so
<agent>.StartTask(trigger)is how a trigger hands work to the loop, and the loop is what runs the agent. - An agent spawned by another agent rides its parent's loop; it starts nothing of its own.
⚠ Do not spin durable machinery for a one-turn answer. "What did we spend on travel in Q3" should cost a model call, not a workflow run. Deciding which is which is your app's job — classify the incoming message first, answer the questions, and start the loop only for the work:
var verdict = Triager.Ask(turns).Text; // cheap: decide whether this is work
if (verdict == "work") {
juniorLegal.StartTask("…"); // durable: the loop takes it from here
}Which loop runs is decided by the task's TYPE#
A loop tracks a task type, and it starts for rows of that type and no other. So an app with more than one kind of agent work declares a task type per kind and a loop per type:
entity PersonalTask : AgentTask { security { allow read when IsAuthenticated; } }
workflow PersonalTaskProcessor {
Tracks = PersonalTask.Status;
Autostart = this.Item.Type != AgentTaskType.Workflow && this.Item.Type != AgentTaskType.Spawn;
Initial = Running;
state Running { }
state Waiting { }
terminal success Completed { }
terminal error Failed { }
}Autostart is a plain condition over the task, written directly — the row is in scope as this.Item, so there is
no lambda to introduce. It is also where "not every task gets one" is stated in your own words rather than assumed:
a task a workflow already drives, or one an agent spawned, is excluded here.
A name that resolves to nothing is refused#
`app.Agent.Loop` names `ProcessTsak`, which is not a declared workflow — add `workflow ProcessTsak { … }`, whose
step runs the agent until it finishes or asks for help. Left unresolved the work would run with no workflow around
it: no durability, no human slot, no trace, and nothing to say so.This is a compile error rather than a runtime one because the runtime failure would be an absence. Nothing throws; the work simply runs bare, and an app that never asked for a loop looks exactly the same. There is no log line to find, because nothing went wrong — something did not happen.
The same check covers the per-agent override, which is the same mistake in the other place it can be written.
The override is on the agent, and the default is resolved at run time#
An agent that declares no Loop stores none — it is not stamped with the app default at compile time. That is
deliberate: changing app.Agent would otherwise leave every previously-compiled agent pointing at the old workflow.
An empty Loop means "ask the app", and it is answered each time the work starts.
An agent declaration seeds a template, and the template carries the Loop too — so an instance minted from it
later inherits the declaration rather than falling back to the default.
Examples#
A real loop, and the reason loops exist. The agent reviews a claim; when it cannot judge one without the person
who filed it, it asks — the run parks in Waiting, and their answer hours later resumes the very same run:
using Osyrin.Agents;
[Principal] entity User {
[Required, MaxLength(255)] string Email;
security { allow read when IsAuthenticated; }
}
entity Claim {
[Required, MaxLength(200)] string Title;
[Required] User Filer;
security { allow read, create, update when IsAuthenticated; }
}
/// What the agent asked, and what it was told. The platform parks the run; what the question LOOKS like to the
/// person answering it is yours.
entity Question {
[Required] Claim Claim;
[Required] ReviewTask Task;
[Required, MaxLength(2000)] string Text;
[MaxLength(2000)] string? Answer;
security { allow read, create, update when IsAuthenticated; }
}
entity ReviewTask : AgentTask {
security { allow read when IsAuthenticated; }
}
agent Auditor {
Purpose = "Review a claim and recommend approve or send-back.";
Prompt = "You review expense claims.";
Memory = Persistent; // an agent that retains nothing cannot come back from a park
Principal = new User { Email = "auditor@example.com" };
Loop = ReviewClaim;
}
workflow ReviewClaim {
Tracks = ReviewTask.Status;
Autostart = true;
Initial = Running;
event Answered(string text);
state Running {
enter {
var claim = Claim.Where(c => c.Id == this.Item.EntityId).FirstOrDefault();
if (claim == null) { goto Failed; }
var turns = new List<Turn>();
turns.Add(Turn.Context("Claim: " + claim.Title));
turns.Add(Turn.User("Review this claim."));
var reply = Auditor.Ask(turns);
if (reply.Parked) { // it stopped to ask a person
OpenQuestion(claim, this.Item, reply.Question ?? "");
goto Waiting;
}
goto Completed;
}
}
state Waiting {
enter { Log.Information("waiting on the filer of {Claim}", this.Item.EntityId); } // notifying is yours
subscribe Answered(string text) as Filer {
Assignee = Claim.Where(c => c.Id == this.Item.EntityId).FirstOrDefault().Filer;
Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Failed; } }
}
on Answered(string text, Slot slot) {
var reply = Auditor.Answer(this.Item, text); // their words reach the agent as its question's answer
CloseQuestion(this.Item, text);
if (reply.Parked) { goto Waiting; } // it asked one more thing — ordinary, not an edge case
goto Completed;
}
}
terminal success Completed { }
terminal error Failed { Message = "the review could not be completed"; }
}
void OpenQuestion(Claim claim, ReviewTask task, string text) {
var q = new Question { Claim = claim, Task = task, Text = text };
UnitOfWork.Commit();
}
void CloseQuestion(ReviewTask task, string text) {
var open = Question.Where(q => q.Task == task && q.Answer == null).FirstOrDefault();
if (open != null) { open.Answer = text; UnitOfWork.Commit(); }
}
/// The trigger. `about:` is what lets the task say which claim it is for.
Guid StartReview(Claim claim) {
return Auditor.StartTask<ReviewTask>("claim submitted: " + claim.Title, about: claim);
}
/// The person's answer, on its way to the parked run.
void AnswerAuditor(Question question, string text) {
ReviewClaim.For(question.Task).Filer.Answered(text);
}Read the three moments in order. StartReview opens the task and returns — the submit gesture does not wait for a
model. Running's enter body runs the agent and branches on the park rather than being thrown out of. And
on Answered hands the person's words back with Answer, which is the hop the whole mechanism is
for: nothing was suspended, so nothing has to be woken.
⚠ Everything in Waiting is yours. Who is asked, how they hear about it, how long they get, what happens when
they do not reply. The platform's half was making the run parkable at all.
A minimal loop, when you only need the wiring — every state present, no body yet:
using Osyrin.Agents;
[Principal] entity User {
[Required, MaxLength(100)] string DisplayName;
security { allow read when IsAuthenticated; allow create when IsAuthenticated || IsAnonymous; }
}
workflow ProcessTask {
Tracks = AgentTask.Status;
Initial = Running;
state Running { }
state Waiting { }
terminal success Completed { }
terminal error Failed { }
}
app.Agent = new AgentConfig { Loop = ProcessTask };
agent Triager {
Purpose = "Triage incoming tickets.";
Prompt = "You triage tickets.";
Principal = new User { DisplayName = "Triager" };
}A chat message that turns into work now opens a task, starts a run of ProcessTask bound to it, and the task's
Status reads whatever state that run is in.
⚠ This one demonstrates the wiring and nothing else — a reader meeting the feature here would learn that a loop is a two-state workflow and come away with no idea why they would want one. The example above it is the feature.
See also#
- an agent asking a person (the human slot) — the ask-a-human hop in full: what parks a run, and what resumes it
- running an agent from your code — running a declared agent from your own code
- Workflow.Work<T> (everything outstanding) and its SLA numbers — the human slot the loop gives an agent that needs to ask
- the agent task log (AgentTask) — the task an agent's work opens, and what it records