Hosting events
A run of a workflow with events parks while it waits: its process exits, and nothing of awf stays running. That is what lets a run wait a day for a reply without holding a process. It also means something outside awf has to come back and wake it. There are two wakers:
awf emit, sent by whatever learns about the event: a webhook handler, a CI job, a git hook, or another workflow. An event wakes its run at once.awf tick, run on a schedule. It continues the runs whose timer is due, and the ones that crashed or failed.
Set up both. Without the first, no events arrive. Without the second, no timer ever fires on a parked run.
A run that needs both
This workflow starts when a deploy finishes, waits an hour for the service to settle, then has an agent check it. A rollback during that hour ends the run without a check:
export default defineWorkflow({
meta: {
name: "post-deploy",
description: "Checks a deploy once it has had an hour to settle.",
},
file: import.meta.path,
events: { "deploy.done": DEPLOYED, "deploy.rolled-back": ROLLED_BACK },
starts: ["deploy.done"],
async run(wf) {
const started = wf.started;
if (started?.name !== "deploy.done")
return wf.stop("started by hand: send deploy.done");
const { service, version } = started.data;
// Parks here: no process until an hour passes or a rollback arrives.
const next = await wf.race(
"settle",
{ settled: "1h", rolledBack: wf.signal("deploy.rolled-back") },
{ label: `${service} ${version} settling` },
);
if (next.rolledBack) return { service, version, checked: false };
const checker = await wf.agents.open({ key: "checker", runtime: "codex" });
const { outcome } = await checker.run({
prompt:
`Read the last hour of ${service}'s logs and metrics ` +
`since ${version} went out. Is it healthy?`,
schema: CHECK,
});
if (!outcome.answered) return wf.stop(`no check: ${outcome.kind}`);
return { service, version, checked: true, ...outcome.value };
},
});The sample is checked and tested: examples/site/guide/hosting-events.ts and its test.
The deploy pipeline starts the run with an event:
awf emit --target ~/ops/post-deploy.ts --id api-v42 deploy.done '{"service":"api","version":"v42"}' --event-id api-v42accepted post-deploy/api-v42: startedThe run reaches the race and parks, and awf runs shows it waiting:
post-deploy/api-v42 parked 11-01 16:14 0s api v42 settling · until 11-01 17:14Then one of two things wakes it:
- a rollback, sent the same way (
awf emit … --id api-v42 deploy.rolled-back '{"version":"v42"}'), which continues it at once; - the first
awf tickafter the hour, which continues it with the timer due, so the checker runs.
awf tick on a schedule
usage: awf tick [--cwd <directory>] [--run-root <directory>] [--json]
awf tick --all [--json]
Wakes every run of a workflow with events under --cwd's .awf/runs, or --run-root, the runs awf
runs lists there, as an event would: continues a parked run whose wait's timer is due, and a
crashed or failed one until it has failed three times in a row. Runs of other workflows are left
as they are. --all wakes them in every project awf has started one in, as ~/.awf/projects lists
them. A project's runs are woken by its own agentswf: one at another version than this awf runs
its own awf tick for them. Run it on any schedule; a run is continued once however many wakers
come at once.
Prints a line for each run it woke, and nothing when it woke none, and one line on stderr
counting runs with events it can't read, which it doesn't wake; --json prints one document,
those runs under unreadable, on exit 1 too, or a refusal whose code says why it failed: usage or
internal-error. Exits 0; 1 when a project is gone, declares agentswf without installing it, or
its own agentswf failed, or a run could not be woken, the others still woken; 2 on a usage
error.awf adds a project to ~/.awf/projects the first time a run with events starts in it, so one scheduled awf tick --all covers every project on the machine. Each time it runs, for every run of a workflow with events, awf tick:
- continues a parked run whose wait has something to take: a timer that is due, or an event that arrived while an earlier wake couldn't start the run;
- continues a crashed or failed run, unless it is
stuck: three failures in a row, the first included, counting crashes, failed attempts and processes that never started, whoever woke it; - starts a run that has no attempt yet, which happens when the event that made it couldn't start its process;
- leaves the rest: a running run, one you stopped or cancelled, and one that returned. Runs of workflows without events aren't looked at.
It prints a line for each run it woke, and nothing when it woke none:
continued post-deploy/api-v42: timer
continued post-deploy/web-v17: crashedEvery wake takes the run's lock, so a tick that comes while an event is waking the same run continues it once, not twice. Overlapping ticks are harmless, and the schedule is only how late a timer may be: with a tick every five minutes, the checker above starts up to five minutes after the hour.
cron
On Linux, one line in crontab -e ticks every five minutes:
*/5 * * * * PATH="$HOME/.bun/bin:/usr/local/bin:/usr/bin:/bin" awf tick --all >> "$HOME/.awf/tick.log" 2>&1awf runs on Bun, so PATH must hold Bun's bin directory, which cron's short default PATH doesn't.
launchd
On macOS, a launch agent does the same. Save this as ~/Library/LaunchAgents/wf.agents.tick.plist, with /Users/you replaced by your home directory (launchd doesn't expand ~ or $HOME):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>wf.agents.tick</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.bun/bin/awf</string>
<string>tick</string>
<string>--all</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/Users/you/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>/Users/you/.awf/tick.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/.awf/tick.log</string>
</dict>
</plist>launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/wf.agents.tick.plist # load it
launchctl bootout gui/$(id -u)/wf.agents.tick # remove itSending events with awf emit
usage: awf emit --target <file> --id <id> <event> [<json>] [--event-id <id>] [--source <name>] [--json]
awf emit <run> <key>:reply '{"text": …, "author": …}' [--event-id <id>] [--source <name>] [--cwd <directory>] [--run-root <directory>] [--json]
Sends an event to the run of a workflow with events that --id names. That run starts if it has
none and the event is one that starts one, and is woken if it has one: told while it runs,
continued when it is parked or crashed. The run lives in the project of <file>, the nearest
directory above it holding .awf/, whoever sends. --event-id names the occurrence: the same id
again is a duplicate. --source names the integration that sent it, such as github, which the
event a wait returns carries.
The second form sends a participant's message to the code of a run that is there, <workflow>/<id>
or an <id> only one workflow has, here or in a project awf knows: <key>:reply, which the workflow
declares, checked against the file the run's last attempt ran. "author" is optional. Its
--event-id is the message's own id, so a resend is a duplicate. It never starts a run, and may
arrive before the run connects the participant: it waits for the run's next wait. The first form
takes only the workflow's own events.
Prints what became of the event, and what was done to the run: accepted, duplicate or refused.
--json prints one document: the result, or a refusal whose code says why it failed: usage,
no-run, ambiguous, id, file, event, prepare, project, no-engine, other-engine, unreachable or
internal-error. Exits 0 for any of the three; 1 when <file> could not be loaded, or the run
isn't found or can't be read, and nothing is kept, or when the run's attempt could not be
started (the event is kept, and the next event or awf tick tries again); 2 on a usage error, for
a workflow with no events, or when the event is wrong for its target: its name, its data, or its
id.awf emit works on the machine that holds the project: it finds the run through the workflow file, in the nearest directory above it holding .awf/. So the sender runs there too, whatever it is:
- A webhook handler on that machine turns a request into
awf emit, with the event's data as its JSON and the source's delivery id as--event-id. - A CI job on a runner on that machine runs
awf emitas a step, as the deploy above does. A hosted runner can't reach the project; it calls a webhook on the machine instead. - Another workflow sends with
wf.emit(target, id, name, data, { eventId }), checked bytscagainst the target's events. Run on a schedule besideawf tick, such a workflow polls a system and sends each change it sees:examples/event-sketches/mr-tick.tssketches one for merge requests.
Give each occurrence an --event-id, such as the delivery id or a commit, so a sender that retries is safe: the same id again is duplicate, not a second event, and it still wakes the run if the first try couldn't. The exit code tells a handler what to answer:
| Exit | What happened | A handler |
|---|---|---|
| 0 | accepted, duplicate or refused (the run returned, or the event can't start one) | answers success |
| 1 | the run's process didn't start, and the event is kept for the next event or awf tick; or the target file couldn't be loaded | answers a server error, so the source retries: with the same --event-id a retry is a duplicate that wakes the run |
| 2 | the event or the command is wrong: a usage error; a target that declares no events, sits directly in your home directory, or whose prepare refuses a run with no arguments; an event name, data or id the target doesn't take | answers a client error |
TUI agents
A run woken by cron, launchd or a webhook has no terminal, and it doesn't need one. Its TUI agents open as they always do, in the Herdr session named awf, which awf starts headless when it isn't running. So the sample's codex agent keeps its default placement, a TUI in a Herdr pane, and you can watch a woken run's agents from any terminal:
HERDR_DISABLE_SOUND=1 herdr session attach awfThe one requirement is that herdr is on the run's PATH, as the harnesses are (see the catches below). Harnesses covers panes and placement.
Catches
- A woken run doesn't get your shell's environment. Its process gets the
PATHrecorded when the run was made, a few plain variables such asHOMEandLANG, and the project's.env. A token exported in your shell never reaches a run cron wakes; put it in the project's.env. - The
PATHthat sticks is the first sender's. A run made by an event records the sender'sPATH, and every attempt after uses it to findclaudeorcodex. A webhook or CI job with a shortPATHmakes runs that can't find their harnesses orherdr: give it aPATHthat holds them. - A failing run gets two more tries, then stops. After a crash or a failure, the next tick or event continues it; the third failure in a row leaves it
stuck, and nothing wakes it again.awf logssays why it failed; once it's fixed,awf runs {run}says what to run next. - A stopped or cancelled run stays that way. Neither a tick nor an event continues it; you do, as Runs shows.
- A workflow that polls on a schedule is a plain run. Nothing keeps two of its runs from overlapping, so give it a schedule longer than it takes.
See also
- Programs: the same calls from a Bun program, typed from the workflow, with no
awfprocess per event. - Events: the workflows these commands wake, their events, timers and waits.
- Durability: what a continue reuses and redoes.
- Following runs:
awf runsandawf logs. - Workflow API: Events:
wf.emit,awf tickand parking, in full.