Agent Use Cases
Also known as: examples use cases recipes shopping assistant deploy bot support triage customer service refund budget spending limit
● Intermediate8 minBefore this: Agents, ask<T>Spec 8.3
Four agents, each with a real risk and the line of code that removes it: a spending limit, a person who approves, a model that can only sort, a folder that can't be left.
Each program here runs as shown, and its output is checked on every build of this site. Copy one and change it: the compiler tells you when a change would let the agent do more than it says.
A shopping assistant with a budget
An assistant buys what the user asks for. A confused model, or a tricky product page, must never empty the account.
What holds it: A limit on each purchase, on the day's total, and on purchases per hour.
type StoreError = OutOfStock | Denied(reason: String)
capability Store {
fn buy(item: String, price: Int) ! StoreError
}
type TestStore: Store {
fn buy(self, item: String, price: Int) ! StoreError {
print("bought {item} for {price}")
}
}
agent Shopper {
can Store.buy
limit Store.buy {
price <= 2_000
sum(price) <= 3_000 per day
count <= 3 per hour
}
on order(item: String, price: Int) ! StoreError {
try Store.buy(item, price)
}
}
fn main() {
const bot = Shopper.start(Store: TestStore())
for r in [(item: "rice", price: 900), (item: "laptop", price: 45_000), (item: "pan", price: 1_500), (item: "tea", price: 800)] {
bot.order(r.item, r.price) catch e => print("not bought {r.item}: {e}")
}
}The program ran and printed:
kavach run uc-shopping.kav
bought rice for 900not bought laptop: Denied(reason: "limit broken: price <= 2_000")bought pan for 1500not bought tea: Denied(reason: "limit broken: sum(price) <= 3_000 per day")
The laptop is over the per-item limit; the tea would take the day past 3,000. Both refusals are errors the program handles, and both are in the audit log.
A deploy bot that asks before production
Releasing to a test server should be quick; releasing to production needs a person on call to agree.
What holds it: approve Deploy.release when env == "production" by "oncall".
type DeployError = BuildFailed | Denied(reason: String)
capability Deploy {
fn release(version: String, env: String) ! DeployError
}
type Pipeline: Deploy {
fn release(self, version: String, env: String) ! DeployError {
print("released {version} to {env}")
}
}
agent Releaser {
can Deploy.release
approve Deploy.release when env == "production" by "oncall"
on ship(version: String, env: String) ! DeployError {
try Deploy.release(version, env)
}
}
fn main() {
const bot = Releaser.start(Deploy: Pipeline())
bot.ship("2.4.0", "staging") catch e => print("not released: {e}")
bot.ship("2.4.0", "production") catch e => print("not released: {e}")
}The program ran and printed:
kavach run uc-deploy.kav
released 2.4.0 to stagingnot released: Denied(reason: "not approved by oncall (env == \"production\")")
Staging goes out at once. Production waits for the on-call person; in this run no one answered, so nothing was released.
A support inbox sorted by AI
Each message goes to the right team, without the model being able to do anything but sort.
What holds it: ask<Triage> can only answer with one of the three teams, and the agent can only assign tickets, at most 200 an hour, with at most 1,000 model calls a day.
type Team = Billing | Engineering | HelpDesk
type Triage {
/// the team that should answer
team: Team
/// one line for that team
summary: String
}
type DeskError = Closed | Denied(reason: String)
capability Desk {
fn assign(team: Team, summary: String) ! DeskError
}
type TestDesk: Desk {
fn assign(self, team: Team, summary: String) ! DeskError {
print("assigned to {team}")
}
}
agent Support {
can Ai, Desk.assign
limit Desk.assign {
count <= 200 per hour
}
limit Ai {
count <= 1_000 per day
}
on message(text: String) ! Error {
const t = try ask<Triage>("Which team should answer this message? {text}")
try Desk.assign(t.team, t.summary)
}
}
fn main() {
const bot = Support.start(Desk: TestDesk())
bot.message("I was charged twice this month") catch e => print("failed: {e}")
}The program ran and printed:
kavach run uc-support.kav
[dry run] ask("Which team should answer this message? I was charged twice t") -> Triage(team: HelpDesk, summary: " fry ey")[dry run] Desk.assign(team: HelpDesk, summary: " fry ey")
Run with --dry-run: the model and the help desk are never called, and the lines show what would have been. Try a new agent this way before it touches anything real.
A file agent that stays in its folder
An agent reads documents from an inbox folder. A file name it is given, by a person or a model, must not reach anything else.
What holds it: The implementation says uses Fs.read("./inbox"): it can only read, and only there.
use std.fs
type InboxError = Missing(name: String) | Denied(reason: String)
capability Inbox {
fn read(name: String) -> String ! InboxError
}
type InboxFolder: Inbox {
fn read(self, name: String) -> String ! InboxError uses Fs.read("./inbox") {
fs.read_text("./inbox/{name}") catch e => match e {
FsError.Denied(path) => fail InboxError.Denied(reason: "outside ./inbox: {path}"),
_ => fail InboxError.Missing(name: name),
}
}
}
agent Summarizer {
can Inbox.read, Console
on summarize(name: String) ! InboxError {
const text = try Inbox.read(name)
print("{name}: {text.len()} characters")
}
}
fn main() ! Error {
try fs.create_dir("inbox")
try fs.write_text("inbox/note.txt", "Call the bank on Monday.")
try fs.write_text("passwords.txt", "do not share")
const bot = Summarizer.start(Inbox: InboxFolder())
bot.summarize("note.txt") catch e => print("failed: {e}")
bot.summarize("../passwords.txt") catch e => print("failed: {e}")
}The program ran and printed:
kavach run uc-files.kav
note.txt: 24 charactersfailed: Denied(reason: "outside ./inbox: ./inbox/../passwords.txt")
../passwords.txt climbs out of the folder, so the read is refused while the program runs, and the agent reports it.
A refund agent, start to finish
The repository's refund bot puts it all together: ask decides, limits cap each refund and the day's total, finance approves large ones, every call is audited, and a crash half way never pays twice.
More ideas
- An expense checker:
askreads each receipt into a typed record, and payouts above a limit go to a manager. - A newsletter sender:
count <= 5_000 per dayonEmail.send, started inobservemode for its first week. - A database clean-up agent: it may archive rows, never delete them, because only
Db.archiveis in itscan.
Every one of these keeps its promise for the same reason: the agent touches the world only through capabilities, and those are checked by the compiler and the runtime, not by the model. See What You Can Build for projects of every level.