Osy#betaRun it on Osyrin: osyrin.com

Why Osy# · Chapter 02 · Agentic apps

An agent is part of the application, not a way around it.

An agent that acts on real data needs an identity, a bounded set of things it may do, and a way to stop and wait for a person. In Osy# an agent is a class in the application: it runs as an account of its own, so every read and every change it makes obeys the entity rules; its skills are typed jobs whose tools are the application's own functions; and what it may not do alone it proposes to a person, whose yes runs as that person. Every piece of work leaves a receipt, and inside a process it is one awaited step.

from reference/agent/agents · abridged[Actor(Roles = [ShopRole.Helper])]
public class Estimator : Agent {
  public static Person Principal => new Person {
    Email = "[email protected]", … };
  public override string Purpose =>
    "You estimate bicycle repairs for a small workshop.";
  public override List<SkillBase> Skills => [new EstimateRepair()];
  public override Permits May => Permits.ChangesAlone();
}

app.Agents = new AgentsSetup { Agents = [new Estimator()], … };
the same page · the rule it obeys, and the call that gives it workentity Repair {
  …
  decimal Price;
  security {
    allow read, update when IsMechanic || IsManager || IsHelper;
    …
    deny update Price when IsHelper;
  }
}

var outcome = await Agents.Of<Estimator>()
  .Do(new EstimateRepair(), repair);
In practiceDeclare one as a class: the account it runs as, the jobs it can do, the functions it may call, and what it must ask a person first.

01

An agent is a class

It has a name, and the name is a type, so the compiler checks every place you use it the way it checks every other name you write. Everything the agent is, it is on this class.

kits/agents/tests/model/app.osyverbatim — this file compiles
/// Grades the fruit that arrives.
1[Actor(Roles = [PackhouseRole.Grader])]
public class Inspector : Agent {
2  public static Person Principal => new Person { Email = "[email protected]", DisplayName = "Inspector" };
3  public override string Purpose => "You grade the fruit that arrives at the packhouse, one crate at a time.";
4  public override List<SkillBase> Skills => [new GradeCrate(), new DescribeDelivery(), new QuickLook(), new ReadTheLedger(), new MarkDelivery()];
  // It marks crates by itself: nothing it does leaves the packhouse.
5  public override Permits May => Permits.ChangesAlone();
}
1

The roles its account holds. The platform makes the account when the app is compiled and keeps its roles exactly as declared. 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.

2

⭐ The agent IS a principal, and this is the important line on the page. It is a row of your own principal entity. Every read it makes goes through your ordinary security { } rules as that account; its access is the application's own model of access, and nothing else.

3

What it is for, in one line. The first thing the model is told, and what a list of agents shows a person deciding whether to trust it with work.

4

What it can be asked to do. Work for a skill it does not hold is refused before it starts, and leaves no receipt.

5

What it does alone. This one marks crates by itself; §5 is the agent that proposes instead.

Two more lines make it live. The app lists it — app.Agents = new AgentsSetup { Agents = [new Inspector(), …] } — and somebody answers for it: an agent nobody sponsors does not run, and a supervisor can pause it. Who may give agents work, who supervises them and who audits what they did are three policies the app answers, and each is nobody's until it does.

02

A skill is a typed job, and its tools are your functions

An agent is not handed an open-ended prompt and a toolbox. It is handed a job with a type going in and a type coming out, and the only things it can call are functions your application already has.

kits/agents/tests/model/app.osyverbatim — this file compiles
/// Mark what was found on a crate: "short", "bruised".
[Action("Flag the crate")]
public string FlagCrate(Crate crate, string flag) {
  crate.Flag = flag;
  return $"{crate.Code} is flagged {flag}.";
}

That is an ordinary function with one attribute. [Action] makes it a tool: its name, its description and the schema of its arguments are the function's own, so nothing is described twice, and what it reads and changes is what the compiler already proved about it.

kits/agents/tests/model/app.osyverbatim — this file compiles
/// Judges one crate.
1public class GradeCrate : Skill<Crate, Grade> {
  public override string Description => "Grade one crate of fruit.";
2  public override string Instructions => "Weigh the crate first. A crate under 10 kg is short: flag it \"short\" and say so in the grade.";
3  public override List<ActionInfo> Tools => [Actions.Of(Weigh), Actions.Of(FlagCrate), Actions.Of(CondemnCrate)];
4  public override string Brief(Crate c) => $"Crate {c.Code}. Its id is {c.Id}.";
5  public override string? Check(Grade g) => g.Score < 0 || g.Score > 10 ? "A score is from 0 to 10." : null;
1

Typed in, typed out. It is given a Crate and hands back a Grade, a class whose schema the model is held to. The caller gets a Grade, not a paragraph to parse.

2

How to do this job, as opposed to what the agent is for. One agent can hold several skills, each with its own instructions.

3

The tools are named, not described. Actions.Of(F) points at the function. A tool the skill does not list is refused whatever the model calls it, and the refusal is on the record.

4

What the model is shown of the input. You decide; the platform does not serialise your row and hope.

5

The schema says a score is a number; Check says it is from 0 to 10. A result it refuses goes back to the model with the reason, and it tries again.

03

It reads and changes only what the rules allow

The tools run as the agent's account, on behalf of whoever asked. A read finds what the agent and that person may both read; a change needs both of them to be allowed. Nothing about that is written for the agent — it is the entity's rule, the same one every page and function obeys.

kits/agents/tests/model/app.osyverbatim — this file compiles
/// One crate of fruit.
entity Crate {
  [Required, Unique, MaxLength(40)] string Code;
  CrateStage Stage = CrateStage.Arrived;
  /// Who brought it.
  [Required] Person Grower;
  decimal Kilos;
  /// What the inspector found: "", "short", "bruised".
  [MaxLength(40)] string Flag = "";
  /// Who changed the crate last.
  [ModifiedBy] Guid? ChangedBy;
  /// Not fit to pack. Only the lead says so.
  bool Condemned = false;
  [MaxLength(300)] string CondemnedWhy = "";
  /// Which line it goes to.
  [MaxLength(40)] string Line = "";
  security {
    // A grower sees and annotates the crates they brought; the lead and the graders see every crate.
1    allow read, update when IsGrower where Grower == user;
2    allow read, update when IsLead || IsGrader;
    allow read when IsAuditor;
    allow create when IsGrower || IsLead;
    // The lead and the graders may condemn a crate; a grower may not — and so nobody may for a grower.
3    deny update Condemned, CondemnedWhy when !IsLead && !IsGrader;
  }
}
1

A grower sees the crates they brought. When the inspector works for a grower, this is the most it can read: what the grower may read, and nothing the grower may not.

2

The inspector's account is a grader, so on its own it may read every crate — which is what it does inside a process, where nobody is signed in and it works with its own roles alone.

3

A grader may condemn a crate; a grower may not — and so nobody may for a grower. The inspector holds the role, but working for a grower the change needs both of them, so the tool is refused and the model is told. The comment above the line says it in one sentence; the rule is what enforces it.

04

Giving it work is one awaited call

You name the agent and the skill and pass the input. What comes back is an outcome: whether the work is done, waiting on a person, or ended some other way, with the typed result when it is done.

kits/agents/tests/model/app.osyverbatim — this file compiles
/// Have the inspector grade a crate, for whoever asks.
public AgentOutcome<Grade> Inspect(Crate crate) {
1  return await Agents.Of<Inspector>().Do(new GradeCrate(), crate, about: "Crate " + crate.Code);
}

/// The same, by the sorter — who has somebody to consult.
public AgentOutcome<Grade> InspectCarefully(Crate crate) {
2  return await Agents.Of<Sorter>().Do(new GradeCrate(), crate, about: "Crate " + crate.Code);
}

/// The same, at a glance and on a small budget.
public AgentOutcome<Grade> Glance(Crate crate) {
  return await Agents.Of<Inspector>().Do(new QuickLook(), crate, about: "Crate " + crate.Code);
}

/// Two crates graded at once: both pieces of work are in flight, and each is awaited where its result is wanted.
public List<AgentOutcome<Grade>> InspectBoth(Crate first, Crate second) {
3  var a = Agents.Of<Inspector>().Do(new GradeCrate(), first, about: "Crate " + first.Code);
  var b = Agents.Of<Inspector>().Do(new GradeCrate(), second, about: "Crate " + second.Code);
  return [await a, await b];
}
1

The agent by its type, the job by its type. Agents.Of<Inspector>() is the agent ready for work; Do answers an AgentOutcome<Grade>. about: is what the receipt says the work was about.

2

The same skill, another agent. The sorter has a budget of its own and a stronger model it may consult when it is unsure; the call site does not change.

3

Two pieces of work in flight at once, each awaited where its result is wanted. It is ordinary async, because it is an ordinary call.

you writeit means
.Do(skill, input)a typed job; answers AgentOutcome<TOut>
.Ask(question)an answer in words; answers AgentReply
.Stream(question)the same answer, handed on as it arrives
.Decide<T>(input, question)a choice among the members of T
.For(person).Do(…)work for that person: never more than they may do

An answer somebody is watching arrives a piece at a time. Stream hands each one on as it lands — text, a tool being called, the end — and it is an ordinary stream<T>: a function iterates it with await foreach, and a page binds it with a live var and saves the finished reply in on settled.

kits/agents/tests/model/app.osyverbatim — this file compiles
/// The same, watched as it arrives: the signals, as words.
public List<string> WatchInspector(string question) {
  List<string> seen = [];
  await foreach (var s in Agents.Of<Inspector>().Stream(question)) {
    if (s.Kind == AgentSignalKind.Text) { seen.Add("text:" + s.Text); }
    else if (s.Kind == AgentSignalKind.Done) { seen.Add("done:" + s.Reply!.Text); }
    else { seen.Add(s.Kind.Name + ":" + s.Tool + (s.Failed ? ":failed" : "")); }
  }
  return seen;
}

05

What it does alone, what it proposes, what it never does

Some changes are harmless and some are money. An agent says, per tool, which it may make by itself; the rest it proposes, and a proposal lands in a person's inbox as an approval.

kits/agents/tests/model/clerk.osyverbatim — this file compiles
/// Says nothing about what it may do alone.
[Actor(Roles = [PackhouseRole.Grader])]
1public class Clerk : Agent {
  public static Person Principal => new Person { Email = "[email protected]", DisplayName = "Clerk" };
  public override string Purpose => "You settle the crates that arrive at the packhouse.";
  public override List<SkillBase> Skills => [new SettleCrate()];
2  public override AsksPeople People => new AsksPeople { MayChoose = true, WhenStuck = true, Most = 2 };
}

/// Moves crates by itself — but a change of line is proposed every time, and it never condemns.
[Actor(Roles = [PackhouseRole.Grader])]
public class Porter : Agent {
  public static Person Principal => new Person { Email = "[email protected]", DisplayName = "Porter" };
  public override string Purpose => "You carry crates where they belong.";
  public override List<SkillBase> Skills => [new SettleCrate(), new SettleByTheBook(), new CountCrates()];
  public override Permits May => new Permits {
3    Changes = Leave.Alone, ProposeEveryTime = [Actions.Of(MoveToLine)], Never = [Actions.Of(CondemnCrate)],
  };
}
1

Saying nothing is a safe answer. The clerk declares no May, so it looks things up alone and proposes every change it wants to make.

2

It may turn to a person — when it judges it should, and once before stuck work gives up, at most twice in one piece of work. The work waits for the answer.

3

Three lists, one line. The porter makes changes alone; proposes a change of line every time, which no "approve all like this" can turn into one it makes alone; and is never offered CondemnCrate at all: a tool in Never is not shown to the model.

A proposal is not a log line. It is a request in a person's inbox that shows the exact call, why the agent wants it, and what it had looked at. The approvals guide runs one end to end — a translation bureau's agent writes its note alone and proposes the fee:

// from reference/agent/guide-approvals · the test, abridged — run by the docs gate
var outcome = Prepare(commission);
Background.Settle();

// The note is written. The fee is not: it is proposed, and the work waits.
Assert.True(outcome.Waiting);
…
// In his inbox: an approval, from the agent, saying what will happen and why.
var approval = Requests.Mine().Single();
Assert.Equal("Preparer proposes: Set the fee", approval.Title);
…
// His yes runs it at once, as him. The agent finishes.
AnswerRequest(approval, RequestAnswer.Approve());
GoOn(outcome.Task);
Assert.Equal(312, Commission.Single().Fee);
Assert.Equal(Otto.Id, AgentProposal.Single().DecidedBy!.Id);

A yes runs as the person who said it. If they may not set a fee themselves, it is not set, and they are told — the same page's second test says yes as a translator and the fee stays at zero. A yes covers what was shown: if the commission changed before anybody decided, nothing runs and the agent is told to look again. And waiting costs nothing: no model is asked while a proposal waits, and the work goes on from where it stood, however many days later. The whole app is one page.

06

Work that takes days is a step of a workflow

A business process is a workflow, and an agent's work is one of its steps: an ordinary awaited call. The process decides what happens when the step is done, is waiting on a person, or cannot finish.

kits/agents/tests/model/clerk.osyverbatim — this file compiles
workflow ClaimFlow {
1  Tracks = Claim.Stage; Autostart = false; Initial = Open;
  /// The grower takes the claim back.
  event Withdraw();
  state Open {
    subscribe Withdraw();
    enter {
      this.Item.Run = Workflow.Current?.Id;
2      var outcome = await Agents.Of<Clerk>().Do(new SettleCrate(), this.Item.Crate, about: "Claim on " + this.Item.Crate.Code);
3      this.Item.Finding = outcome.Done ? outcome.Result.Finding : outcome.Status.Name + ": " + outcome.Problem;
      goto Settled;
    }
4    on Withdraw { goto Withdrawn; }
  }
  terminal success Settled { }
  terminal cancel  Withdrawn { Message = "the grower took the claim back"; }
}
1

The process is about the claim, and its state is a column on it — the same workflow model as every other process in the application.

2

The agent's work is a step. The same call as §4, awaited inside a state's enter. If the clerk proposes something, the step waits for the person — and the run waits with it, as durable state, for days if it takes days.

3

An outcome, not an exception. The process branches on whether the work was done like on anything else, and decides what an unfinished step means for the claim.

4

Stopping the process stops the work. The test below withdraws the claim while the clerk's proposal waits for the lead.

kits/agents/tests/processes.test.osyverbatim — this file compiles
void stopping_the_process_calls_off_the_work_it_was_waiting_for() {
  runas (Ada) {
    var crate = Crate.Single(c => c.Code == "A-2");
    var script = AgentScript.Of(
      Says.Tool("It is short.", Actions.Of(FlagCrate), FlagArgs(crate, "short")),
      Says.HandBack(new Grade { Score = 4, Finding = "Short." }));
    ScriptedAnswer.Stub((model, request) => { new Heard { What = "asked", About = model }; return script.Answer(model, request); });
    OpenClaim(crate);
    Workflow.Settle(Claim.Single());
  }
  runas (Dana) {
    Assert.Equal(AgentTaskStatus.Waiting, AgentTask.Single().Status);
1    Assert.Equal(ProposalStatus.Waiting, AgentProposal.Single().Status);
  }
  // The grower takes the claim back while the clerk's proposal waits for the lead.
  runas (Ada) {
2    WithdrawClaim(Claim.Single());
    Workflow.Settle(Claim.Single());
    Assert.Equal(ClaimStage.Withdrawn, Claim.Single().Stage);
  }
  Background.Settle();
  runas (Dana) {
    Workflow.Settle(AgentWorkWait.Single());
    Assert.Equal(AgentWorkWaitStatus.CalledOff, AgentWorkWait.Single().Status);
    // The app is told that nobody waits for the work any more — and the kit calls it off.
    Assert.Equal(1, Heard.Count(h => h.What == "no longer awaited"));
    GoOn(AgentTask.Single().Id);
    var receipt = AgentTask.Single();
    Assert.Equal(AgentTaskStatus.Cancelled, receipt.Status);
    Assert.Equal("It was called off: The process that was waiting for it was stopped.", receipt.Outcome);
    // Nothing more was asked of a model, the proposal can no longer be approved, and nothing it proposed was done.
3    Assert.Equal(1, Heard.Count(h => h.What == "asked"));
    Assert.Equal(ProposalStatus.Withdrawn, AgentProposal.Single().Status);
    Assert.Throws<AgentRefused>(() => ApproveAgentProposal(AgentProposal.Single()));
    Assert.Equal("", Crate.Single(c => c.Code == "A-2").Flag);
    Assert.True(VerifyTrace(receipt).Intact);
  }
1

Flagging a crate is a change, and the clerk proposes it. The work is waiting on the lead, and the claim's run is waiting on the work.

2

The grower takes the claim back while the proposal waits.

3

The work is called off, not abandoned. No model is asked again, the receipt says why it ended, and the proposal can no longer be approved — nothing it proposed was done.

An app that lists its workflows as processes (Osysharp.Processes) gets every run told in order, each agent step on it with what it cost, and a budget per run: agents inside a business process is that app, whole.

07

Every piece of work leaves a receipt

Who asked, the account it ran as, what it cost against its limit, how it ended — and a record of each step in a plain sentence. Only the kit writes either, and the record is chained so a changed line is found.

kits/agents/tests/trace.test.osyverbatim — this file compiles
void the_record_says_every_step_in_order_in_plain_words() {
  var task = GradedByAda();

  Assert.Equal(["Started", "ModelCall", "ToolCall", "ToolResult", "ModelCall", "ToolCall", "ToolResult", "ModelCall", "Ended"],
1               Trajectory.Kinds(task));
  Assert.Equal([
    "Inspector was given grade crate: Crate A-2.",
2    "Small (chosen by llm:default) asked for a tool; it cost $0.0006.",
    "It asked for Weigh (reads only); it is one of this work's tools, so it runs as the agent, for whoever asked.",
    "Weigh answered.",
    "Small (chosen by llm:default) asked for a tool; it cost $0.0007.",
3    "It asked for FlagCrate (changes data); it is one of this work's tools, so it runs as the agent, for whoever asked.",
    "FlagCrate answered.",
    "Small (chosen by llm:default) asked for a tool; it cost $0.0009.",
    "Done. It handed back its result.",
  ], Trajectory.Said(task));
}
1

Every step, in order: each model call, each tool asked for and what it answered, and how it ended.

2

A model call says which model, why it was chosen, and what it cost — the same cost the model kit's meter charges to this piece of work, call for call.

3

A tool call says what kind of tool it is and whose rights it ran under: the agent's, for whoever asked.

Each line carries the SHA-256 of the line before it, so VerifyTrace names the first line that was changed, removed or slipped in. Whoever asked reads their work's record, a supervisor reads every receipt but not the content, an auditor reads everything; app code that writes or rewrites a line is denied. What a record said can be erased for a person who asks, and the chain still checks. The receipt and the record has the rest.

What happens when you change it

The changes a real app makes to an agent, and what happens

Each of these is a thing you would do on a Tuesday. The verdict is the compiler's, not a convention's.

You do thisVerdictBecause
Give the agent's class a role that reads morecompilesIt reads more — through your ordinary security rules, with no agent-specific path. That is the point of it being a principal.
Have it work for a grower, and let the model try to condemn that grower's cratecompilesIt compiles, and the change is refused when it runs: working for a person, a change needs both the agent and that person to be allowed. The model is told, and the refusal is on the record.
Put a tool in NevercompilesIt is not offered to the model at all. What an agent may never do is a declaration, not a hope about the prompt.
Approve a proposal you could not make yourselfcompilesNothing is done, and you are told. A yes runs as the person who said it, with their rights.
Give work to an agent nobody listed, sponsored, or one that is pausedcompilesIt compiles, and the work is refused before it starts: it throws, and leaves no receipt.
Write a receipt or a line of the record from app codecompilesIt compiles and is denied when it runs. Only the kit writes them, and a line changed underneath is found by VerifyTrace.
Deploy while a piece of work waits on a personcompilesNo model is asked while it waits, and the work goes on from where it stood. The wait is a workflow's, so it survives a deploy like any parked run — see Resumes without repeating.