Agentic apps
An agent is a declaration, a principal and a workflow — not a library you call. This is how you declare one, how its answer streams into a page a token at a time, and what happens when it needs to ask a person something and wait two days for the reply.
01
An agent is a declaration
Not a client you construct and configure at a call site. It has a name, and the name is what you call — so the compiler checks it exists, the way it checks every other name you write.
agent Auditor { 1 Purpose = "Review a submitted expense report against the travel policy and recommend approve or send-back."; 2 Prompt = """ You review expense reports for a small company. For each line, judge whether the claim is plausible and within policy: • travel and accommodation are reimbursable in full; • meals are reimbursable up to 60 per person per day; • software is reimbursable when it is a work tool. A line whose amount was TYPED rather than read from a receipt is not wrong — it is unverified, and you should say so. A line whose receipt was read and disagrees with the typed amount IS a finding. When a line genuinely cannot be judged without the employee — an amount far outside policy with no explanation, a merchant that could be personal, a missing receipt on a large claim — ask them, once, in plain words. Do not ask about anything you can decide yourself; you are spending their hours, not yours. Finish by delivering a short document: your recommendation (approve, or send back), and the lines behind it. """; 3 Model = "claude-haiku-4-5-20251001"; 4 Memory = Persistent; 5 Principal = new User { Email = "auditor@ledger.demo", DisplayName = "Auditor" }; 6 Roles = [Role.Finance]; 7 Loop = ReviewExpenses; 8 security { allow read Transcript when IsFinance; } }
What it is for, in one line. This is not the prompt. It is what a list of agents shows, what a person approving the work reads, and what another agent is told when it is deciding whether to hand this one a task.
The instructions. A triple-quoted string, so it is written as prose rather than as a concatenation. It lives on the declaration, which means every call site gets the same one and none of them repeats it.
The model, by name. Admitted by app.Models, so an app says which models it will run at all — and naming one it does not admit is a compile error, not a bill.
It remembers across runs. The alternative is per-task, and the choice is a property of the agent rather than of the call, because an agent whose memory depends on who called it is two agents.
⭐ The agent IS a principal, and this is the important line on the page. It signs in as somebody. Every read it makes goes through your ordinary security { } rules as that somebody — there is no agent-shaped hole in your model, and no separate set of permissions to keep in step with the human ones.
The roles that principal holds. An agent that should not see salaries is an agent without the role that grants them; you do not filter what you hand it, you decline to give it the role.
The workflow its work runs inside. §4. Without one, the agent answers in the turn and that is all — which is right for an agent that only ever answers.
The agent's own transcript is data, so it is governed like data. What the model was actually shown is often the most sensitive thing in the system, and here it is one line rather than a policy document.
02
Calling it
You assemble the turns; the agent brings itself. Which history to replay and which facts to include are product decisions, and a platform that guessed would guess wrong in both directions.
var turns = new List<Turn>(); turns.Add(Turn.Context("Acme Ltd, plan Enterprise, customer since 2019")); turns.Add(Turn.User("Is the Lisbon dinner within policy?")); AgentReply reply = Triager.Ask(turns);
| you write | it means |
|---|---|
Turn.User(text) | somebody is asking |
Turn.Assistant(text) | the agent said this earlier — replayed history |
Turn.Context(text) | facts you supplied. Not dialogue |
agent.Ask(turns) | run it here, now; answers an AgentReply |
agent.StartTask<T>(trigger) | hand the work to its loop instead |
03
The answer arrives a piece at a time
An assistant's reply is not a value that appears; it is one that accumulates. Osy# has a return kind for exactly that, and it is the same one a log tail and a long import use.
1stream<string> Answer(Guid chatId) { var prompt = "You are a helpful assistant. Continue the conversation.\n\n" + Transcript(chatId); foreach (var piece in LlmClient.Stream(prompt)) { 2 yield return piece; } }
stream<T> is a return kind. The function produces its results one at a time instead of all at once. Everything else about it is an ordinary server function — it reads data, it obeys the same security.
yield return hands one item over and carries on. Same word as C#, same meaning. There is no yield break: a bare return; is unambiguous here, because a stream never returns a value.
A component observes it with an ordinary live var, and renders each piece as it lands. Nothing polls, nothing re-fetches, and no item is rendered twice:
/// The model's answer, arriving. [Composable] component Reply(Guid chatId) { 1 live var answer = Answer(chatId); 2 on settled(answer) { Save(chatId, string.Concat(answer)); } render { Stack(gap: Space.S, w: "100%") { 3 Markdown(string.Concat(answer), streaming: !answer.Done); if (answer.Failed) { Text(answer.Error ?? "", color: Colors.Subtle); } else if (!answer.Done) { Text("…", color: Colors.Subtle); } } } }
The stream is the subscription. An ordinary server call cannot be a live var — nothing would keep it current. A stream can, because the connection stays open and the server pushes.
A hook for "it finished". This is where the finished reply is written to the database — after the last piece, exactly once, whichever way it ended.
The Markdown control is told it is still arriving, so it renders a half-written fence as a fence rather than as three stray backticks — and it keeps the DOM of every block that did not change.
What the connection actually is
One Server-Sent Events stream, and it is worth knowing two things about it, because both are guarantees you would otherwise have to build.
event: open data: {"run":"c1f2…"} // the run id goes FIRST, before any item event: item data: "Reviewing the " event: item data: "Lisbon dinner…" event: done
A reconnect replays, it does not re-run. The viewer asks to follow from item N, and the server replays what it already produced and then keeps following. A first connection, a dropped connection and a page reload are the same code path differing only in that number — which is what makes reconnecting safe: item 4 is always the same item 4, and the model is never billed twice for it.
The producer says how it ended, so the page can tell the three apart.
Done | Failed | Interrupted | what to show | |
|---|---|---|---|---|
| the function ended | true | false | false | the finished list |
| it raised an error | false | true | false | the items so far, plus Error |
| the connection dropped | false | true | true | the items so far, and an offer to retry |
| still producing | false | false | false | the items so far, and a spinner |
04
Work that takes days runs inside a workflow
An agent that answers in one turn needs nothing around it. An agent that reads several things, calls tools, waits on a person and resumes hours later needs everything a workflow already provides — so it runs inside one, and you say which.
workflow ReviewExpenses { 1 Tracks = ReportReview.Status; Autostart = true; Initial = Running; /// Raised when the employee answers the agent's question. The workflow SLOT is what records who answered and how /// long they took, which is what makes "it waited 15 hours for Sam" answerable without storing a duration. event Answered(string text); state Running { enter { var report = ExpenseReport.Where(r => r.Id == this.Item.EntityId).FirstOrDefault(); if (report == null) { goto Failed; } var turns = new List<Turn>(); turns.Add(Turn.Context(DescribeReport(report))); turns.Add(Turn.User("Review this expense report and recommend approve or send-back.")); 2 var reply = Auditor.Ask(turns); 3 if (reply.Parked) { OpenQuestion(report, this.Item, reply.Question ?? ""); goto Waiting; } goto Completed; } } /// PARKED ON A PERSON. Everything in this state is POLICY — who is asked, how they hear about it, how long they /// get — and it is all the app's. The platform's half was making the run parkable at all. state Waiting { enter { Log.Information("expense report {Report}: the auditor is waiting on an answer", this.Item.EntityId); } 4 subscribe Answered(string text) as Submitter { Assignee = ExpenseReport.Where(r => r.Id == this.Item.EntityId).FirstOrDefault().Employee; Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Failed; } } }
The loop's subject is the task. The agent task's own Status column is the workflow's state, so "what has this agent done, or what is it waiting for" stays one question and one column to list on.
The same call as §2 — inside a workflow step, so the run survives a restart and a deploy, and a resumed run does not re-ask the model.
It asked a person. Not an error and not a guess: the reply carries the question, and the run goes to a state that means waiting.
The wait is a modelled fact. Who is asked, how long they get, and what happens if they never answer — all declared. The Finished { Within = … } clock is why "it waited fifteen hours for Sam" is answerable without anyone storing a duration.
When the answer arrives, Auditor.Answer(task, text) resumes the same work from exactly where it stopped. Nothing was held open in the meantime — there is no paused process, only a row that says a question is outstanding.
05
…and the same agent is an assistant in your app
There is no second surface for this. The chat window is a page, the reply is a stream, and the history is an ordinary entity your security rules already cover.
component ChatPage(Guid id) { live var turns = ChatMessage .Where(m => m.Session.Id == id && !m.IsSummary) .OrderBy(m => m.Sequence); string draft = ""; string asking = ""; bool pinned = true; action Send() { if (draft != "") { Post(id, draft); asking = draft; draft = ""; } }
That is the transcript: a live query over a table you declared, ordered, filtered to the conversation on screen, and re-derived the moment a row lands. The assistant's half is the Reply component from §3. Between them there is no API, no socket to manage and no state to reconcile — which is the same claim this site makes about every other page, and the reason the agent surface did not need its own machinery.
06
What happens when you change it
The changes a real app makes to an agent, and what the compiler does about them
Loopapp.Models does not admitstream<T> to a plain varlive var, which is the construct that means this is still arriving.Where to go next
The loop in §4, in full — states, events, slots and clocks.
Why a call to a model is not made twice.
Every construct on this page, one page each.