Capability: lifecycle hooks
Code of the operator's own, asked at the moments a work item's delivery turns — a start, a session launch, a wait on a person, a phase change, the end — before the-loop acts; declared once in the CLI config; run in-process or over JSON-RPC (issue-344).
What it is
A curated catalog of seven lifecycle points, each with a typed context of facts a hook reads and decisions a hook may change, and one executor interface — a LifecycleHooks subclass — that the-loop runs synchronously in the path of the thing it decides. The operator declares executors under the top-level hooks of cli-config.yaml; each runs locally (module/path) or as a remote JSON-RPC service (url), authored against the_loop.sdk.hooks.
It is distinct from the process graph's hooks (a check at a node boundary, pass | block | wait | skip) and from the event log (a record of what happened). The closed first attempt (PR #357) made every event type an attach point; the owner's ruling — "centered around events … a disaster" — is the constraint this capability is built around (decision-137).
Current behaviour
The catalog
- The system SHALL define exactly these points, in this order, as the only names a declaration may reference:
work_item_start,session_spawn,session_spawned,waiting_for_input,input_received,phase_changed,work_item_complete.the-loop hooks pointsSHALL list them with their facts and decisions.EVENT_TYPESSHALL NOT be a source of points. - Each point SHALL fire synchronously, before the thing it decides:
work_item_startonce per arming before the workspace is prepared;session_spawnbefore every harness launch (first spawn, pull-request session, respawn);session_spawnedafter registration, before the announcement;waiting_for_inputbefore an agent's question is published and when a human node is entered;phase_changedwhen the phase label changes or a terminal node is reached, before the channels are told;work_item_completewhen the item's ticket or pull request closes, before the closure is announced. - Every context SHALL be facts plus marked decision fields with JSON types, and SHALL carry the work item as a modelled entity (
WorkItem: ref, provider, host, owner, repo, repository, number, kind, url, id), never as a bare string. The system SHALL read back decisions only, refuse a wrong-typed decision as that hook's failure, and ignore a changed fact.
Decisions and their effects
input_receivedSHALL fire when a person's input reaches the loop — an answer throughthe-loop reply(CLI, API, a channel), a comment or review about to be delivered into a session, a control command from the ticket or a control verb — before the-loop delivers, reads or runs it. Itstextdecision SHALL replace what is delivered (an answer, a comment's body);proceed: falseSHALL drop it: a comment is not delivered and the ticket gets one marked comment (input-refused), a ticket command is rejected (control.rejected, reasoninput-refused), a reply or verb is refused to its caller.proceed: falseatwork_item_startSHALL disarm the work item, post one marked comment naming the reason, settle the event without retry, and recordhooks.refused.proceed: falseatsession_spawnSHALL prevent the launch (first spawn, respawn: settled as refused with the comment; a pull request's session: delivered into the work item's session instead).promptSHALL replace the text the harness boots on.announce: falseatsession_spawnedSHALL skip the session announcement; atwork_item_completeit SHALL skip thework-item.closedpublish.notify: falseatphase_changedSHALL skip thephase.*publishes for that transition.questionandsummaryatwaiting_for_inputSHALL replace an agent's published question; for a gate they SHALL be empty and ignored.- With no hooks declared, every one of those behaviours SHALL be exactly what it was.
Declaration and loading
- The CLI config SHALL accept a top-level
hooks[]:name(^[a-z][a-z0-9-]*$, unique) and exactly one ofmodule,path(resolved against the config file's directory, never a checkout),url;executor/withon a local entry;tokenEnv/headers/timeoutSeconds(default 10) on a remote one;on(a subset of the catalog),required(default false),enabled(default true). A malformed entry SHALL fail the load naming it. - The key
onSHALL be accepted bare, as every documented example writes it, as well as quoted:yaml.safe_loadfollows YAML 1.1 and reads the bare key as the booleanTrue, and the loader SHALL map that key back toonbefore any other rule sees the entry. An entry carrying both spellings SHALL be refused. A refusal for an unknown key SHALL be aHooksConfigErrornaming the entry and every offending key whatever its type (issue-433). - A
urlSHALL behttps, orhttpto a loopback host;httpelsewhere with atokenEnvSHALL be refused at load.tokenEnvSHALL name a variable read at call time; anAuthorizationheader inheadersSHALL be refused. - A local module SHALL define exactly one
LifecycleHookssubclass, orexecutorSHALL name one (a class, constructed withwith, or an instance); an import or construction failure SHALL fail the load naming the entry. - A daemon (poller, receiver, service) SHALL load the declaration at start and refuse to start on one it cannot load. A one-shot command SHALL load it lazily on first use and, when that fails, run hookless and record
hooks.load_failed. The declaration SHALL be read once per process.
Execution and failure
- Executors SHALL run in declaration order; each SHALL see the earlier ones' decisions. A
Nonereturn SHALL pass the context through; a returned context of the same type SHALL replace its decisions; a remote result object SHALL replace the decisions it names. - A hook that raises, times out, answers badly or cannot be reached SHALL be recorded (
hooks.failed) and skipped; the chain SHALL continue and the operation SHALL proceed. Arequiredhook failing at aproceedpoint SHALL setproceed: falsewith a reason naming it. No exception from the chain SHALL reach a dispatch, an advance or an ask. - A changed decision SHALL be recorded as
hooks.decided(point, hook, changed fields).
The remote protocol and the SDK
- A remote executor SHALL send one JSON-RPC 2.0
POSTper invocation (methodthe point,paramsthe context, an integerid) and readresult: nullor an object of decisions;error, a non-2xx status, a timeout or a mismatchedidSHALL be the hook's failure. the_loop.sdk.hooksSHALL exportLifecycleHooks, the seven contexts,POINTS,handle_request(the server half for any HTTP framework) andHookServer(a stdlib server with an optional bearer check andGET /health), importing no FastAPI, uvicorn or MCP.the-loop hooksSHALL report the declaration without importing a module or contacting a URL, exiting 1 on one that cannot be read.
Design
docs/specs/issue-344/design.md · hooking the lifecycle · hooks options · the-loop hooks · decision-137
History
| Work item | What changed | Links |
|---|---|---|
| issue-433 | The declaration loader accepts the bare key on (2026-09-29): yaml.safe_load reads it as the boolean True, so a hook declared exactly as the docs show was refused as having an unknown key, and the refusal itself raised TypeError joining a non-string key instead of the HooksConfigError that names the entry. The key is normalised before the unknown-key check, both spellings on one entry are refused, and unknown keys are rendered with repr whatever their type | spec, issue |
| issue-344 | Capability minted: seven lifecycle points with typed contexts (facts + decisions), the LifecycleHooks executor interface, the top-level hooks[] declaration (local module/path, remote url over JSON-RPC), the chain runner with warn-and-continue and required, the-loop's own announcements consulting the seam, the-loop hooks, and the_loop.sdk.hooks. Supersedes the event-centred attempt of PR #357 | spec, decision-137, issue |