Tutorial: from one-shot agent to durable daemon
This tutorial walks an agent through every rung of the lifecycle ladder: from
a single agent_loop call that returns once, to a parked worker that survives
process restart, to a bounded pool of agents that wake on channel events.
Each step builds on the previous one and runs unmodified on a fresh harn
install — no API keys, no extra setup, only the mock provider so the output
is deterministic.
If you want the long-form reference for any primitive used below, see Agent lifecycle, Pipeline lifecycle, Pool stdlib, and Agent channels. The lifecycle cookbook collects production-shaped recipes that compose the same primitives.
Each step prints a small diagnostic line ("status=...", "snapshot=...") so you can compare your run against the expected output. The mock provider prints a one-time hint about Ollama on first use; the tutorial output below elides it.
1. One-shot agent
Start with the smallest useful loop: one prompt, one mocked response, one result. The pipeline returns as soon as the loop completes.
pipeline main() {
llm_mock({text: "Triaged ticket #42 as duplicate of #38."})
let result = agent_loop(
"Triage ticket #42.",
"You are a careful triage assistant.",
{provider: "mock"},
)
log("status=" + result.status)
log("text=" + result.visible_text)
}
$ harn run step1.harn
status=done
text=Triaged ticket #42 as duplicate of #38.
This is the baseline. The loop ran one turn and returned status: "done".
Nothing else is happening — no channels, no checkpoints, no resume.
2. Capture unsettled work at finish
Real pipelines spawn subagents, queue pool tasks, and emit channel events
that may outlive the body. The runtime exposes that work through
harness.unsettled_state(); pipeline_on_finish registers a callback that
fires after the pipeline returns and decides what to do with whatever is
still in flight. The on_finish_drain preset walks the unsettled buckets
and applies a default disposition (cancel, acknowledge, defer, drain) per
item.
import { on_finish_drain } from "std/lifecycle"
pipeline main() {
pipeline_on_finish(on_finish_drain)
llm_mock({text: "Triaged ticket #42 as duplicate of #38."})
let result = agent_loop(
"Triage ticket #42.",
"You are a careful triage assistant.",
{provider: "mock"},
)
log("status=" + result.status)
log("text=" + result.visible_text)
}
The visible output matches step 1 — the loop returns naturally, so there is
nothing unsettled to act on. The registration becomes load-bearing in step
3, where a parked worker stays in suspended_subagents until the drain
callback walks the bucket. See Pipeline lifecycle presets
for the rest of the preset family (on_finish_abandon,
on_finish_handoff_to, on_finish_block_until_settled) and
Pipeline lifecycle for the full callback
contract.
3. Self-park mid-loop
agent_loop exposes agent_await_resumption to the model as a callable
tool. When the model wants to wait on an external signal (a human review, an
upstream merge, anything off-VM), it calls that tool and the loop yields
between turns. The pipeline gets back status: "suspended" with a snapshot
path.
import { on_finish_drain } from "std/lifecycle"
pipeline main() {
pipeline_on_finish(on_finish_drain)
llm_mock({
tool_calls: [{
id: "park_1",
name: "agent_await_resumption",
arguments: {reason: "waiting on maintainer review"},
}],
})
let result = agent_loop(
"Triage ticket #42, escalate to a human if you need review.",
"If you need a human review before proceeding, call agent_await_resumption.",
{provider: "mock", tool_format: "native", max_iterations: 2},
)
log("status=" + result.status)
log("reason=" + result.reason)
log("snapshot=" + result.handle.snapshot_path)
}
$ harn run step3.harn
status=suspended
reason=waiting on maintainer review
snapshot=.harn/workers/worker_019e57c5-….json
The snapshot is a JSON document on disk. The runtime persists the full transcript, the parsed conditions, the resume responsibility, and enough session metadata to rehydrate in a different process. Suspend is cooperative — the loop honors the request at the next turn boundary, not mid-tool-call. See Agent lifecycle § When to suspend for the rest of the ways an agent can yield and which one to reach for.
4. Resume from the snapshot
The snapshot from step 3 is enough to drive the worker forward in any
process. The CLI does this with harn run --resume <snapshot>: it
rehydrates the worker, replays a single-shot resume_continuity system
reminder onto the next turn, and finishes the loop. The script below
runs both halves in one process so the tutorial stays self-contained;
the prose after the snippet shows the cross-process command.
import { on_finish_drain } from "std/lifecycle"
import { resume_agent, wait_agent } from "std/agent/workers"
pipeline main() {
pipeline_on_finish(on_finish_drain)
llm_mock({
tool_calls: [{
id: "park_1",
name: "agent_await_resumption",
arguments: {reason: "waiting on maintainer review"},
}],
})
llm_mock({text: "Approved. Triaged ticket #42 as duplicate of #38."})
let first = agent_loop(
"Triage ticket #42, escalate to a human if you need review.",
"If you need a human review before proceeding, call agent_await_resumption.",
{provider: "mock", tool_format: "native", max_iterations: 3},
)
log("first.status=" + first.status)
log("snapshot=" + first.handle.snapshot_path)
resume_agent(first.handle)
let done = wait_agent(first.handle)
log("after_resume=" + done.status)
log("text=" + done.result.summary)
}
$ harn run step4.harn
first.status=suspended
snapshot=.harn/workers/worker_019e57c9-….json
after_resume=completed
text=Approved. Triaged ticket #42 as duplicate of #38.
To do the same thing across processes, run step 3, copy the printed snapshot
path, and run harn run --resume <path> --json. The --json flag dumps the
final loop value as a structured envelope so it can be piped into another
tool. Snapshots live under .harn/workers/ by default; the path is
script-relative, so resume the script from the same working directory.
5. Wake on a channel event
Step 3 left the worker parked open — only an operator can resume it. Most
real waits have a concrete signal to listen for: a PR merging, a release
cutting, a calendar event firing. Attach conditions.trigger to the
agent_await_resumption call and the runtime registers the trigger with
the dispatcher; firing the trigger drives the worker to completion with
initiator: "triggered" and no explicit resume_agent call from the
pipeline.
import { on_finish_drain } from "std/lifecycle"
pipeline main() {
pipeline_on_finish(on_finish_drain)
llm_mock({
tool_calls: [{
id: "park_for_release",
name: "agent_await_resumption",
arguments: {
reason: "waiting on release.cut",
conditions: {
trigger: {
kind: "channel.emit",
provider: "channel",
match: {events: ["channel:release.cut"]},
},
},
},
}],
})
llm_mock({text: "Release cut; tagged v0.9.0 and posted to the changelog."})
let worker = sub_agent_run(
"Tag the next release once the maintainer signals.",
{provider: "mock", background: true, tool_format: "native", max_iterations: 3},
)
let parked = wait_agent(worker)
log("parked_status=" + parked.status)
log("waiting_on=" + parked.suspension.conditions.trigger.match.events[0])
emit_channel("release.cut", {tag: "v0.9.0"})
let done = wait_agent(worker)
log("final_status=" + done.status)
log("final_text=" + done.result.summary)
}
$ harn run step5.harn
parked_status=suspended
waiting_on=channel:release.cut
final_status=completed
final_text=Release cut; tagged v0.9.0 and posted to the changelog.
wait_agent blocks until the worker reaches a terminal or parked state,
which gives the pipeline a deterministic point to fire the event. Any
trigger kind that trigger_register accepts works as a resume condition —
GitHub webhooks, file watchers, calendar events, custom providers. See
Agent channels for the channel surface and
Agent lifecycle § Conditioned resume
for the rest of the ResumeConditions shape (timeouts, on_event,
resume_by).
6. Fan out under a bounded pool
A real product runs many agents at once but only so many in parallel. The
std/lifecycle/pool registry shares a single concurrency budget across
submissions; max_concurrent caps the active slot count, the rest queue.
Pool tasks compose with the same waiter as agent handles, so pool_wait
(or wait_agent) collects results uniformly.
import { on_finish_drain } from "std/lifecycle"
import { pool_create, pool_wait } from "std/lifecycle/pool"
pipeline main() {
pipeline_on_finish(on_finish_drain)
let tickets = ["t-101", "t-102", "t-103"]
for ticket in tickets {
llm_mock({
match: "*" + ticket + "*",
text: "Triaged " + ticket + " as duplicate of t-100.",
consume_match: true,
})
}
let pool = pool_create({name: "ticket-triage", max_concurrent: 2})
var handles = []
for ticket in tickets {
let t = ticket
handles = handles + [pool.submit({ ->
return agent_loop(
"Triage ticket " + t + ".",
"You are a careful triage assistant.",
{provider: "mock"},
)
})]
}
let outcomes = pool_wait(handles)
for outcome in outcomes {
log(outcome.status + " " + outcome.result.visible_text)
}
}
$ harn run step6.harn
completed Triaged t-101 as duplicate of t-100.
completed Triaged t-102 as duplicate of t-100.
completed Triaged t-103 as duplicate of t-100.
The pool ran two agents concurrently and queued the third. Combine the pool with the parking pattern from step 5 to fan a queue of long-running human-in-the-loop reviews across a bounded worker set — each pool task spawns an agent that parks waiting for its own signal, the orchestrator emits the signals as approvals land, and the resume-continuity reminder (auto-injected on every resume; visible in the persisted transcript) tells the model what changed during the pause so it picks up without re-reading the whole conversation.
See Pool stdlib for queue strategies, backpressure, and fairness keys; the Pool cookbook shows the production-shaped patterns.
Where to go next
- Agent lifecycle — the long-form reference for
every suspend/resume primitive used above, plus
ResumeBy.*for naming who owns the resume. - Pipeline lifecycle cookbook — multi-pipeline hand-off, custom drain policies, supervised suspend denial, replay- deterministic test harnesses.
- Daemon stdlib — first-class
daemon_spawn/daemon_trigger/daemon_resumewrappers when a worker needs to outlive a single pipeline run. harn run --resumein the CLI reference — the out-of-process resume path used in step 4.