Authoring guide¶
How to structure WhippleScript workflows well: modeling data, sequencing effects, branching, composing source, and debugging runs. New users should start with the quickstart and tutorial; exact syntax for every construct is in the language reference.
The premise behind all of the guidance here: rules own policy and stay deterministic; everything external is a durable effect; the runtime owns delivery, retries, idempotency, and inspection.
Model data with classes and enums¶
Facts are typed. Use enums for closed decision sets and literal fields for small state machines:
enum ReviewStatus {
Accept
Revise
Blocked
}
class WorkItem {
id string
title string
status "queued" | "reviewed"
}
class WorkReview {
status ReviewStatus
reason string
confidence float
}
Literal-typed status fields keep guards deterministic and make illegal states
unrepresentable: a rule matching where item.status == "queued" cannot also
see a reviewed item.
Prefer consuming and re-recording facts over mutating status in place —
done item -> record WorkItem { ... status "reviewed" } makes the state
transition atomic with whatever else the rule commits.
Request agent work¶
agent worker {
provider fixture
profile "repo-writer"
capacity 1
capabilities ["agent.tell"]
}
rule implement
when WorkItem as item where item.status == "queued"
when worker is available
=> {
tell worker requires ["agent.tell"] as turn """markdown
Implement this item:
{{ item.title }}
"""
after turn succeeds as completed {
done item -> record WorkItem {
id item.id
title item.title
status "reviewed"
}
}
}
tell records an effect; the provider runs later. Three consequences worth
internalizing:
- Source order inside a rule body does not order effects. Use
afterblocks to create dependency edges. - Effect output (
completedabove) is visible only inside theafterblock that proves the terminal status. This is what makes causality auditable. when worker is availableis the capacity gate; without it the rule still fires but the effect may block on capacity.
Make typed model decisions with coerce¶
When a judgment call should produce structured data rather than prose, declare a coerce function and branch on its completion:
coerce reviewWork(title string, summary string) -> WorkReview {
prompt """markdown
Review the completed work.
Title: {{ title }}
Summary: {{ summary }}
{{ ctx.output_format }}
"""
}
rule review
when ...
=> {
tell worker as turn "..."
after turn succeeds as completed {
coerce reviewWork(item.title, completed.summary) as review
}
after review succeeds as result {
record ReviewedWork {
item item
review result
}
}
after review fails as failure {
askHuman "Review failed: {{ failure.reason }}"
}
}
coerce is an effect, not a function call: it is durable, it can fail, and
its typed output is only available in the after branch.
Branch deterministically¶
Guards handle filtering; case handles finite domains; after ... completes
handles exhaustive terminal-status handling:
case review.status {
Accept => {
record AcceptedWork { id item.id }
}
Revise => {
tell worker as revision "Revise {{ item.title }}."
}
Blocked => {
askHuman "Blocked: {{ review.reason }}"
}
}
after turn completes {
case turn.output {
Completed as result => { record TurnSucceeded { summary result.summary } }
Failed failure => { record TurnFailed { reason failure.reason } }
TimedOut as timeout => { record TurnTimedOut { reason timeout.reason } }
Cancelled c => { record TurnCancelled { reason c.reason } }
}
}
Branch over typed values. Never parse prompt text to decide a route or a
status — if a decision needs model judgment, make it a coerce and branch on
the typed result.
Sequential flows¶
Most orchestration is best expressed as independent rules: each reacts to the
facts it cares about, and the runtime sequences them through after edges.
But some work is genuinely a script — do this, then ask a human, then branch —
and threading it through nested after blocks obscures the sequence. A flow
writes that sequence top to bottom while lowering to the same rules:
flow triage
when Ticket as ticket
{
tell triager as turn "Plan {{ ticket.title }}."
askHuman as signoff "Approve {{ turn.summary }}?"
when signoff.choice == "approve" {
complete result { decision signoff.choice }
} else {
fail error { reason "rejected" }
}
}
Inside a flow, each effect step's output is in scope for the steps that follow
(turn, then signoff), so you do not nest after blocks for the common
case. Attach on fails { ... } / on timeout { ... } to a step to handle its
failure paths, and use when <expr> { } else { } to branch on a prior step's
output.
When to reach for a flow versus plain rules:
| Situation | Use |
|---|---|
| Steps that always run in a fixed order, with shared bindings | flow |
| Independent reactions that fan in from different facts | separate rules |
| One linear path with a human gate in the middle | flow |
| Branchy policy where order is data-driven, not positional | rules + guards |
A flow is not a new runtime mode. It compiles to ordinary rules
(flow.<name>.seg0, seg1, …) plus a generated await state, all visible in
whip check. Everything you know about rules — atomic commits, durable
effects, after semantics — still applies.
Work queues¶
When work arrives as a backlog rather than as facts you seed up front, declare a queue and let rules claim from it:
queue backlog {
tracker builtin
}
rule pick_up
when backlog has ready item as item
when worker is available
=> {
claim item as work
tell worker as turn "Resolve {{ work.title }}."
after work fails as taken {
// another claimant won the race — just wait for the next ready item
}
}
The verbs are file item into <queue> { ... }, claim, release, and
finish. A losing claim is a normal branchable failure, not an error, so a
contended queue stays correct without locks in source.
Operate the backlog from the CLI with whip items add, whip items list, and
whip items show. The builtin tracker is workspace-scoped and issues ids like
WS-1; items an agent files mid-turn carry run-identity provenance. Full
syntax is in the language reference.
Time and deadlines¶
Keep time out of guards — guards must stay pure. Express deadlines as effects:
timeout <dur>on an effect bounds how long it may run; anafter ... times out/on timeoutbranch reacts when it expires.timer <dur> as deadlineis a standalone delay you branch on withafter deadline succeeds.cancel <binding>stops a pending or running effect you bound earlier.
tell worker as turn timeout 10m "Do the work."
after turn times out as t {
askHuman "Worker exceeded 10m — escalate?"
}
Durations are <n><unit> with units s/m/h/d. Timers and timeouts fire
on worker passes — there is no daemon — so whip dev --until idle treats
pending timers as idle, and whip status lists the time effects an instance is
waiting on.
Express retries as facts¶
There is no built-in retry policy; retries are ordinary facts and rules, which keeps them visible and auditable:
rule attempt_job
when Job as job where job.status == "pending" and job.attempts < 3
when worker is available
=> {
tell worker as turn "Do job {{ job.id }}"
after turn succeeds as ok {
done job -> record JobDone { id job.id }
}
after turn fails as failed {
done job -> record Job {
id job.id
attempts job.attempts + 1
status "pending"
}
}
}
rule give_up
when Job as job where job.attempts >= 3
=> {
done job -> record JobAbandoned { id job.id }
}
Gate on humans¶
askHuman creates an inbox item; a human answered rule reacts to the
answer. The tutorial builds this pattern end to end, and
examples/human-review.whip is the minimal
version. Operate the inbox with whip inbox, whip inbox show <item>, and
whip inbox answer <item> (--choice X | --text "...") [--by NAME].
Run a local command (escape hatch)¶
exec has a dev form and a hosted form.
exec "<command>" as result runs a local command as an effect and exposes
result.exit_code and result.stdout. It is a dev-profile escape hatch,
deliberately constrained:
- There is no source syntax to grant it. The operator allow-lists commands
through
WHIPPLESCRIPT_EXEC_ALLOW(colon-separated glob prefixes such asscripts/*); anything outside the list fails and routes toafter x fails. - There is no sandbox for raw dev
exec— a grant is a documented trust decision. Keep the allow-list as narrow as the workflow needs, and prefer agents, package capabilities, or child workflows when one of those fits.
Hosted deployments should use named script capabilities instead:
The operator supplies --exec-profile hosted --script-manifest <path>. The
manifest maps backup_repo to argv, a pinned SHA-256 digest, and optional
secret references:
{
"backup_repo": {
"argv": ["bash", "scripts/backup.sh"],
"sha256": "9f2c...",
"env": { "BACKUP_TOKEN": "env:BACKUP_TOKEN" }
}
}
Hosted exec rejects raw command strings, verifies the script bytes before
spawn, runs argv-direct with typed JSON stdin, and records the executing hash.
Use exec for deterministic local steps that genuinely belong in the
workflow — running a test script, a linter — not as a way to smuggle
orchestration into shell.
Compose source¶
| Need | Use |
|---|---|
| Split declarations across files | include "schemas/common.whip" |
| Bring in coerce classes/functions | include "review.coerce" |
| Import package/library surface | use memory |
| Reuse a rule/effect fragment at compile time | pattern + apply |
| Sequence fixed steps with shared bindings | flow |
| Pull work from a durable backlog | queue + claim |
| Run work with its own lifecycle and terminal contract | workflow + invoke |
Patterns are compile-time templates — apply expands them into ordinary
declarations before type checking. invoke is runtime composition — the
child is a real instance, and the parent sees only its declared output or
failure payload:
invoke ReviewPhase {
phase PhaseReviewRequest {
id phase.id
title phase.title
}
} as child
after child succeeds as result {
record ReviewComplete { phaseId phase.id result result }
}
after child fails as failure {
record ReviewBlocked { phaseId phase.id reason failure.reason }
}
End the workflow¶
Declare what the workflow produces, and make some rule produce it:
complete result { ... } and fail error { ... } are atomic with the rule
commit and validate against the declared contract. whip check rejects
workflows with no path to a terminal; tag genuinely perpetual workflows
@service and externally-fed rules @external (see
liveness checks).
Remember the failure split: a provider failure is effect/run state for rules
to react to; fail is the workflow itself giving up. Don't conflate them —
deciding which provider failures are fatal is exactly the policy the source
should express.
Debug a run¶
Work through the views in order:
whip status <instance> # lifecycle, counts, recent events
whip log <instance> # the event sequence
whip facts <instance> # current fact state
whip effects <instance> # effect status + policy_block_reason
whip runs <instance> # provider attempts
whip diagnostics <instance> # recorded errors
whip evidence <instance> # provider payloads and artifacts
whip trace <instance> --check # lifecycle conformance
whip --json dev <file> --provider fixture --until idle plus assertions is
the tightest authoring loop: assertions turn "it seems to work" into a
checked claim about the final state.
Checklist before sharing a workflow¶
whip checkpasses;@service/@externaltags appear only where intentional.- Effects are ordered by
afterblocks, not source order. - Routing decisions are typed source data (
AgentRef, enums, literals) — not model output. - Every failure branch retries, escalates, or deliberately ignores; none fall through silently.
- Agent profiles are as narrow as the work allows; package calls are explicit
calleffects; skills are attached to agents or turns, not imported. whip --json devpasses its assertions with the fixture provider, andwhip trace --checkreports conformance.
Common mistakes¶
| Mistake | Fix |
|---|---|
| Relying on source order to sequence effects | Use after effect succeeds/fails/completes. |
Reading effect output outside its after branch |
Bind output inside the branch that proves the status. |
Treating coerce as a local function call |
Branch on the effect's completion. |
| Letting a model choose the provider or route | Use AgentRef<...>, enums, or literal fields. |
| Treating provider failure as workflow failure | Write a rule that decides when to fail. |
Importing skills with use |
Attach skills to agents or turns; use imports package/library surface. |
| Hiding orchestration in shell scripts around the CLI | Express it as rules, facts, and effects. |
| Reading the clock in a guard | Use a timeout, timer, or recorded fact. |
Treating a lost claim as an error |
Branch on the claim failure and wait for the next ready item. |
Reaching for emit to log an event |
emit was removed; derive facts from effect completions. |
Granting raw dev exec broadly |
Keep WHIPPLESCRIPT_EXEC_ALLOW as narrow as the workflow needs; use hosted script capabilities for untrusted authoring. |
Credentials in .whip source |
Use provider configuration references. |