Tutorial: build a triage workflow¶
In this tutorial you write a workflow from an empty file and run it to a completed instance. The workflow seeds two bug tickets, has an agent propose a triage plan for each, asks a human to sign off on the high-severity one, and completes or fails depending on the answer.
Along the way you will use most of the language: typed facts, a seed table,
an agent, effect sequencing with after, a human review gate, guards, and
workflow terminals. The fixture provider stands in for a real agent, so no
credentials are needed.
Install the CLI first if you have not.
1. Declare the data¶
Create triage.whip. Start with the workflow's name, its terminal
contracts, and the fact types it works over:
workflow TicketTriage
output result TriageDecision
failure error TriageBlocked
class Ticket {
id string
title string
severity string
status string
}
class TriagedTicket {
id string
title string
severity string
plan string
status "triaged"
}
class TriageDecision {
decision string
decidedBy string
}
class TriageBlocked {
reason string
}
Two things to note:
outputandfailuredeclare what this workflow produces when it ends. Every workflow must end (or be explicitly tagged as a service); the checker enforces this below.status "triaged"is a literal type: aTriagedTicketcan only ever have that status. Literal fields are the idiomatic way to model small state machines.
2. Declare the agent and seed data¶
agent triager {
provider fixture
profile "repo-reader"
capacity 1
}
table tickets as Ticket [
{
id "T-31"
title "Login returns 500 on empty password"
severity "high"
status "open"
}
{
id "T-32"
title "Typo in footer copyright"
severity "low"
status "open"
}
]
The agent binds to the fixture provider for now; switching to a real
provider later changes this one line, not the rules. The table seeds two
Ticket facts when the instance starts.
3. Write the triage rule¶
rule triage_open_ticket
when Ticket as ticket where ticket.status == "open"
when triager is available
=> {
tell triager as turn """markdown
Suggest an owner and a fix plan for this ticket:
{{ ticket.title }} (severity: {{ ticket.severity }})
"""
after turn succeeds as triaged {
done ticket -> record TriagedTicket {
id ticket.id
title ticket.title
severity ticket.severity
plan triaged.summary
status "triaged"
}
}
}
This is the core pattern of the language:
- The
whenclauses say what the rule waits for: an open ticket, and free capacity on the agent. telldoes not call the agent. It records a durableagent.telleffect; a worker executes it later through the provider.after turn succeeds as triagedruns when the effect completes successfully — minutes or days later — with the turn's output bound totriaged. Output is only visible inside theafterblock.done ticket -> record ...consumes the open ticket and records its replacement in the same atomic commit, so a ticket can never be both open and triaged.
4. Let the checker catch the missing ending¶
Check what you have so far:
error: workflow `TicketTriage` has no rule that reaches `complete` or `fail`
= help: add a rule that runs `complete <output> { ... }` or `fail <failure> { ... }`,
or tag the workflow `@service` if it intentionally runs forever
The checker is right: tickets get triaged, and then nothing ever finishes the workflow. Add the human gate and the two endings.
5. Add the human approval gate¶
rule request_signoff
when TriagedTicket as ticket where ticket.severity == "high"
=> {
askHuman as signoff choices ["approve", "reject"] """markdown
{{ ticket.title }} was triaged with this plan:
{{ ticket.plan }}
Approve or reject the plan.
"""
}
rule approve_plan
when human answered signoff as answer where answer.choice == "approve"
=> {
complete result {
decision answer.choice
decidedBy answer.answered_by
}
}
rule reject_plan
when human answered signoff as answer where answer.choice == "reject"
=> {
fail error {
reason "triage plan rejected"
}
}
askHuman creates a human.ask effect that surfaces as an inbox item. The
choices list limits the accepted answers and gives answer.choice a
string-literal-union type. When someone answers it, the runtime records a fact
that human answered ... rules match; the answer payload carries choice,
text, and answered_by.
Finally, add assertions — executable claims about the finished run that
dev evaluates for you:
Check again; it should pass and print the compiled rule graph:
6. Run it¶
mkdir -p .whipplescript
whip --store .whipplescript/triage.sqlite \
dev triage.whip --provider fixture --until idle
dev reports both assertions passing, and status shows the instance is
still running — it is waiting for the sign-off:
whip --store .whipplescript/triage.sqlite status <instance_id>
whip --store .whipplescript/triage.sqlite inbox
key_... instance=ins_... severity=normal created=...
Login returns 500 on empty password was triaged with this plan: ...
Only the high-severity ticket produced an inbox item; T-32 was triaged without ceremony.
7. Answer and finish¶
Answer the review, then step the instance so the decision rules see it:
whip --store .whipplescript/triage.sqlite inbox answer <item_id> --choice approve --by alice
whip --store .whipplescript/triage.sqlite step <instance_id> --program triage.whip
whip --store .whipplescript/triage.sqlite status <instance_id>
The durable record of the whole run is now queryable:
TriagedTicket {"id":"T-31","plan":"...","severity":"high","status":"triaged",...}
TriagedTicket {"id":"T-32","plan":"...","severity":"low","status":"triaged",...}
agent.turn.completed {"agent":"triager","provider":"fixture","status":"completed",...}
agent.turn.completed {"agent":"triager","provider":"fixture","status":"completed",...}
human.answer.received {"answer":{"answered_by":"alice","choice":"approve",...},...}
effects shows the two agent turns and the human ask, all completed;
trace --check verifies the lifecycle conforms to the runtime model.
8. Try the failure path¶
Run a fresh instance and reject the plan:
whip --store .whipplescript/triage.sqlite \
dev triage.whip --provider fixture --until idle
whip --store .whipplescript/triage.sqlite inbox
whip --store .whipplescript/triage.sqlite inbox answer <item_id> --choice reject --by alice
whip --store .whipplescript/triage.sqlite step <instance_id> --program triage.whip
whip --store .whipplescript/triage.sqlite status <instance_id>
The reject_plan rule executed fail error { ... }. Both instances — one
completed, one failed — coexist in the same store with full histories.
Where to go next¶
- Swap
provider fixturefor a real provider once you have one configured: providers & packages. - Add a typed model review with
coerceinstead of trusting the turn summary: seeexamples/queue-worker-with-review.whip. - Read the manual for authoring guidance — retries, branching, composition — and the language reference for the full construct list.