Summary#
foreach (var step in task.Watch()) {
// step.Text — "Using Search", "recommending approval"
}One foreach. It replays what already happened, keeps going as more happens, and finishes when the task reaches a
terminal state. Arriving late is not a special case — a watcher who opens a task halfway through sees the same
thing as one who was there from the start.
Signature#
task.Watch() // → stream<AgentStep>AgentStep | |
|---|---|
Sequence | order within the whole job, from 1 |
Kind | Said · ToolCall · ToolResult · Asked · Answered · Finished |
Text | the line a progress view shows |
Detail | the tool name, the terminal state, the failure |
IsError | the tool failed (ToolResult only) |
At | when it happened |
⚠ It is a method, not a property — the parens are carrying meaning. task.Calls is a
value, complete when you get it; this opens something with a lifetime.
Description#
Watching is free, and so is looking away#
The task's work is driven by its loop, not by you. So:
- open it and you see everything so far, then everything next;
- close it and the task carries on exactly as before;
- come back and call
Watch()again — it catches you up.
There is no cursor to keep, no reconnect to write, and no state on the client at all. That is the whole reason this is a single member rather than a history call plus a live subscription: the moment they are two, every caller has to join them, and get the join right.
⚑ A watcher must live inside something that can pass items on as they arrive — so a function reading a stream is
itself declared stream<T>. The compiler enforces this, which is what stops a watcher being written as an ordinary
function that quietly blocks until the task ends.
stream<string> Progress(Guid taskId) {
var task = ReviewTask.Where(t => t.Id == taskId).FirstOrDefault();
foreach (var step in task.Watch()) {
yield return step.Text;
}
}Does it cover child tasks too?#
A task driven by a loop makes no model call itself — the run it starts is a child task, and the work is recorded
against the child. Watch() therefore covers the task and everything beneath it, which is what makes it show
anything at all on the task you were handed. Same reason [[agent-task-calls#allcalls|AllCalls]] exists.
It ends when the task ends#
Completed, failed, or stopped — the stream finishes and the foreach exits. A task that is
Waiting on a person is not terminal and the stream stays open: that is the case a watcher most wants to be
attached for, because the thing being waited on is usually them.
Who can read it#
If you can read the task, you can read its progress. Steps hang off the task, so there is no second rule to declare and none to forget:
partial entity AgentTask {
security { allow read when IsAuthenticated; }
}⚠ A step says what the agent did — "Using Search", "recommending approval". It never carries what a tool
returned, because a tool result is the agent's own read performed with the agent's authority. That is
task.Transcript, behind its own gate.
Examples#
using Osyrin.Agents;
[Principal] entity User {
[Required, MaxLength(255)] string Email;
security { allow read when IsAuthenticated; }
}
entity ReviewTask : AgentTask {
security { allow read, update when IsAuthenticated; }
}
/// A progress feed a screen can bind straight to.
stream<string> Progress(Guid taskId) {
var task = ReviewTask.Where(t => t.Id == taskId).FirstOrDefault();
foreach (var step in task.Watch()) {
yield return step.Sequence.ToString() + ". " + step.Text;
}
}See also#
- stopping work (task.Stop) — ending work that is still running, including the model call it is in
- what a task cost, and what it did (task.Calls) — what the same work cost
- what the agent saw (task.Transcript) — what the agent actually read and wrote, behind its own gate