You are here: Building with agents › Deciding what to build › Shape 2
Summary#
The translation bureau from shape 1 now lets its agent work out a fee. Writing a note is harmless, so the agent does that alone. Setting a fee is money, so the agent proposes it: the manager finds an approval in his inbox that says what will happen, to which commission, and why. He says yes, and the fee is set — as him, with his rights.
This is the second shape: work done for people, with approvals.

A proposal as a person sees it, from a purchasing office's app. The two buttons are Approve and Reject.
Description#
What does it add to shape 1?#
| You add | It gives you |
|---|---|
| [[agent-kit-human-requests|Osysharp.HumanRequests]] | a question, an approval or a form put to a person, and the answer brought back |
| [[agent-kit-work-queues|Osysharp.WorkQueues]] | the inbox those requests wait in |
| [[agent-kit-agents-human-requests|Osysharp.Agents.HumanRequests]] | nothing to write: it arrives by itself when the two above are both in the app, and turns an agent's proposal into an approval in the inbox |
What do you decide now?#
- What the agent does alone, what it proposes, what it never does. One line on the agent:
new Permits { Alone = [Actions.Of(NoteCommission)] }. Everything else that changes something is proposed. A tool listed inNeveris not even offered to the model. See [[agent-autonomy#gate]]. - Who a proposal goes to. Said nothing, it goes to the person the work is for. A skill can send it elsewhere, to a queue or to one person: "fees go to Finance". See [[agent-autonomy#route]].
- Who else may decide. Whoever the app's
DecidesAgentProposalsadmits may decide a proposal that was put to somebody else. By default that is whoever supervises the agents. See [[agent-roles#deciders]]. - Who may say "from now on, do this alone". The policy
WidensAgentAutonomy. Nobody, until the app says who. See [[agent-autonomy#standing]]. - Where requests wait and how long.
app.AgentRequestsnames the queue and the time a decision is wanted within. See An agent's proposals and questions in a person's inbox, and an inbox item in an agent's hands (Agents.HumanRequests kit).
What holds, whatever you decide?#
- A yes runs as the person who said it. If they may not do the thing themselves, it is not done, and they are told. The test below shows both cases.
- A yes covers what was shown. If the thing changed before the person decided, nothing runs and the agent is told to look again. See [[agent-autonomy#approve]].
- Waiting costs nothing. While a proposal waits, no model is asked. The work goes on from where it stood, however many days later. See [[agent-people#waiting]].
- A no needs a reason, and the agent is given it.
What does the whole app look like?#
What is new since shape 1 is marked NEW.
app Bureau {
use Osysharp.Agents;
use Osysharp.Agents.Testing;
use Osysharp.Agents.HumanRequests; // NEW: the bridge — `osy lock` adds it by itself to an app that uses both kits
use Osysharp.HumanRequests; // NEW
use Osysharp.WorkQueues; // NEW
use Osysharp.Llm;
use Osysharp.Identity;
use Osysharp.Organizations;
use Osysharp.Organizations.ServicePlans;
use Osysharp.Presence;
use Osysharp.Observability;
use Osysharp.Workflow;
use Osysharp.ServiceHours;
use Osysharp.Ui;
model "model/**/*.osy";
}
using Osysharp.Identity;
using Osysharp.Organizations.ServicePlans;
using Osysharp.WorkQueues;
using Osysharp.HumanRequests;
using Osysharp.Agents;
using Osysharp.Agents.Testing;
using Osysharp.Agents.HumanRequests;
using Osysharp.Llm;
using Osysharp.Workflow;
using Osysharp.ServiceHours;
app.Llm = new LlmSetup {
Models = [ new LlmModel { Name = "Everyday", Client = new ScriptedModel { Name = "Everyday" }, Model = "scripted-everyday",
Price = new LlmPrice { Input = 1, Output = 5 }, MaxOutputTokens = 1000 } ],
Default = "Everyday",
};
app.Agents = new AgentsSetup { Agents = [new Preparer()], TaskBudget = 0.05m };
// NEW — decision 5: proposals wait in the Bureau queue, and are wanted within a working day.
app.AgentRequests = new AgentRequestsSetup { Queue = "Bureau", DecideWithin = TimeSpan.FromHours(8) };
app.Inbox = new InboxSetup { DefaultQueue = "Bureau" };
[Role] enum BureauRole { Authenticator, Translator, Manager, Assistant }
partial entity RoleGrant { security { allow read when IsAuthenticated; } }
policy IsTranslator => RoleGrant.Any(g => g.Member == user && g.Role == BureauRole.Translator);
policy IsManager => RoleGrant.Any(g => g.Member == user && g.Role == BureauRole.Manager);
policy IsAssistant => RoleGrant.Any(g => g.Member == user && g.Role == BureauRole.Assistant);
policy IsOffice => IsTranslator || IsManager;
policy UsesAgents => IsOffice;
policy SupervisesAgents => IsManager;
policy AuditsAgents => IsManager;
policy WidensAgentAutonomy => IsManager; // NEW — decision 4: who may say "from now on, alone"
policy ReadsModelUsage => IsManager;
policy ManagesModelSpend => IsManager;
// NEW — the inbox's and the requests' own questions.
policy WorksInbox => IsOffice || IsAssistant;
policy LeadsInbox => IsManager;
policy AdministersInbox => IsManager;
policy ManagesOrganizations => IsManager;
policy CollaboratesOnRecords => IsOffice;
policy AsksPeople => IsOffice;
policy OverseesRequests => IsManager;
entity Person : Principal {
security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}
partial entity ServiceHours { security { allow read when IsOffice; } }
partial entity ServiceWindow { security { allow read when IsOffice; } }
partial entity ServiceException { security { allow read when IsOffice; } }
partial entity WorkItem { security { allow read when IsOffice || IsAssistant; } }
/// One text a customer has sent in to be translated.
entity Commission {
[Required, Unique, MaxLength(40)] string Reference;
[Required, MaxLength(200)] string Title;
[MaxLength(40)] string Into = "";
int Words = 0;
[MaxLength(400)] string Note = "";
/// NEW: what the customer is charged. The manager sets it — and so may the agent's own account, which, working for
/// a person, does it only when that person may too.
decimal Fee;
security {
allow read, update when IsOffice || IsAssistant;
allow create when IsOffice;
deny update Fee when !IsManager && !IsAssistant;
}
}
public class Preparation {
public int Terms;
public string Summary = "";
}
[Action("Look up the house terms for a language", Query)]
public List<string> HouseTerms(string language) {
return language == "Finnish" ? ["sopimus: agreement", "toimitus: delivery"] : [];
}
[Action("Write a note on the commission")]
public string NoteCommission(Commission commission, string note) { commission.Note = note; return $"Noted on {commission.Reference}."; }
// NEW: a tool that changes money.
[Action("Set the fee")]
public string SetFee(Commission commission, decimal fee) { commission.Fee = fee; return $"{commission.Reference} is charged {fee}."; }
public class PrepareCommission : Skill<Commission, Preparation> {
public override string Description => "Prepare one commission for its translator, and work out its fee.";
public override List<ActionInfo> Tools => [Actions.Of(HouseTerms), Actions.Of(NoteCommission), Actions.Of(SetFee)];
public override string Brief(Commission c) => $"Commission {c.Reference}: {c.Title}, {c.Words} words into {c.Into}. Its id is {c.Id}.";
// NEW: a person reads the commission's reference on the proposal, not its id.
public override string Names(string action, string argument, string value) {
if (argument != "commission") { return value; }
var c = Commission.FirstOrDefault(x => x.Id == Guid.Parse(value));
return c == null ? value : "Commission " + c!.Reference;
}
}
[Actor(Roles = [BureauRole.Assistant])]
public class Preparer : Agent {
public static Person Principal => new Person { Email = "[email protected]", DisplayName = "Preparer" };
public override string Purpose => "You prepare commissions for the translators of a small bureau.";
public override List<SkillBase> Skills => [new PrepareCommission()];
// NEW — decision 1: it writes its note alone. Everything else that changes something is proposed.
public override Permits May => new Permits { Alone = [Actions.Of(NoteCommission)] };
}
public AgentOutcome<Preparation> Prepare(Commission commission) {
return await Agents.Of<Preparer>().Do(new PrepareCommission(), commission, about: "Commission " + commission.Reference);
}
[TestFixture]
void Office() {
var ines = new Person { Email = "[email protected]", DisplayName = "Ines Varga" };
var otto = new Person { Email = "[email protected]", DisplayName = "Otto Reyes" };
new RoleGrant { Member = ines, Role = BureauRole.Translator };
new RoleGrant { Member = otto, Role = BureauRole.Manager };
new OrganizationTier { Name = "Standard", Rank = 1, IsDefault = true };
var queue = new Queue { Name = "Bureau", Position = 1 };
new QueueMember { Queue = queue, Member = ines };
new QueueMember { Queue = queue, Member = otto };
new Commission { Reference = "C-31", Title = "Supply agreement", Into = "Finnish", Words = 2400 };
new AgentSeat { Agent = "Preparer", Sponsor = otto };
}
principal Ines => Person.Single(p => p.Email == "[email protected]");
principal Otto => Person.Single(p => p.Email == "[email protected]");
AgentScript NoteThenFee(Commission c) {
var id = "{\"commission\":\"" + c.Id.ToString() + "\"";
return AgentScript.Of(
Says.Tool(Actions.Of(NoteCommission), id + ",\"note\":\"Two house terms apply.\"}"),
Says.Tool("2,400 words at the legal rate.", Actions.Of(SetFee), id + ",\"fee\":312}"),
Says.HandBack(new Preparation { Terms = 2, Summary = "Noted, and a fee of 312." }));
}
/// Let the work a person has answered go on: the request's own handlers, then the wait's run.
void GoOn(Guid task) {
Background.Settle();
Workflow.Settle(AgentWait.Where(w => w.Task.Id == task).OrderByDescending(w => w.Since).First());
}
[Test(Office)]
void The_manager_finds_the_proposal_in_his_inbox_and_his_yes_runs_it_as_him() {
runas (Otto) {
var commission = Commission.Single();
var script = NoteThenFee(commission);
ScriptedAnswer.Stub((model, request) => script.Answer(model, request));
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);
Assert.Equal("Two house terms apply.", Commission.Single().Note);
Assert.Equal(0, Commission.Single().Fee);
// In his inbox: an approval, from the agent, saying what will happen and why.
var approval = Requests.Mine().Single();
Assert.Equal(RequestKind.Approval, approval.Asks);
Assert.Equal("Preparer proposes: Set the fee", approval.Title);
Assert.Equal("2,400 words at the legal rate.", approval.Context);
var card = ProposalCardOf(AgentProposal.Single().Id)!;
Assert.Equal("Commission C-31", card.Lines[0].Value);
// 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);
Assert.Equal(AgentTaskStatus.Done, AgentTask.Single().Status);
}
}
[Test(Office)]
void A_yes_from_somebody_who_may_not_set_a_fee_does_not_set_it() {
runas (Ines) {
var commission = Commission.Single();
var script = NoteThenFee(commission);
ScriptedAnswer.Stub((model, request) => script.Answer(model, request));
var task = Prepare(commission).Task;
Background.Settle();
// The proposal comes to her, who asked. A translator may not set a fee.
AnswerRequest(Requests.Mine().Single(), RequestAnswer.Approve());
GoOn(task);
Assert.Equal(0, Commission.Single().Fee);
Assert.Equal(ProposalStatus.Failed, AgentProposal.Single().Status);
Assert.StartsWith("It was approved, and could not be done:", ProposalCardOf(AgentProposal.Single().Id)!.Outcome);
}
}What would make this the wrong shape?#
- The approval belongs to a fixed step of a longer process with several people. Go to shape 3.
- The same kind of thing is approved every time and nobody ever says no. Give a [[agent-guide-glossary#standing-rule|standing rule]], or let the agent do it alone: see [[agent-guide-choosing#alone-or-propose]].
What to read next#
- Shape 3 — agents inside a business process — the same app, grown: the agent's work becomes a step of a business process
- What a person sees — the screens of an app with agents in it — the inbox, the approval and the question, as screenshots
- Choosing — I want X, so use Y, not Z, because — alone, propose, or a standing rule?
Go deeper#
- What an agent does alone, what it proposes, what it never does — the gate and proposals (Agents kit) — the gate, proposals, what a yes covers, standing rules
- An agent's proposals and questions in a person's inbox, and an inbox item in an agent's hands (Agents.HumanRequests kit) — proposals and questions as requests in the inbox
- An agent asks a person, and waits — the person rung of the ladder of help (Agents kit) — an agent asks a person a question, and waits
- Requests to people — a question, an approval, a form, and the answer coming back (HumanRequests kit) — the requests kit: questions, approvals, forms
- The WorkQueues kit — queues, who holds what, and the service levels promised on any item — the inbox itself