Lifecycle Hooks
Hooks let workspace admins run arbitrary logic when platform events fire — pre/post task delegation, agent start/stop, tool calls, LLM calls, memory writes, peer conversations, approvals, budget overruns, guardrail trips. They are the orchestration-layer equivalent of Claude Code’s shell hooks but generalised across three handler kinds: shell, http, and subagent.Events
Defined ininternal/hooks/types.go (14 events offered to users):
There is no
pre_tool_call. The platform drives agent work by parsing the
stream a CLI (Claude Code, Cursor, …) emits as it runs; by the time a
tool_call event reaches the orchestrator the tool has already executed, so
there is no “before the tool runs” interception point to hook. pre_tool_call
was briefly offered as a creatable event with no dispatch site behind it —
registrations against it looked fine and never fired — and was removed
rather than left dead. The hooks.EventPreToolCall Go constant still
exists (existing hooks_config rows may still carry it), but
ValidateEvent rejects it for new writes.Coverage status
All 14 events offered to users are dispatched from a real call site.internal/hooks/dispatch_site_test.go’s TestEveryOfferedEventHasADispatchSite
enforces this as an invariant, not a claim to take on faith: it source-scans
the tree for a real hooks.Dispatch call for every event in AllEvents and
fails if one is missing, so this table cannot silently drift the way it did
for pre_tool_call (declared, accepted, dispatched by nothing, for months).
-
pre_agent_start,post_agent_stop— orchestrator agent lifecycle (internal/orchestrator/orchestrator_run.go). Both payloads carrymission_idso a hook can scope its action to the running mission without a separate join. -
on_approval_requested— fires when Harbormaster’sGatereturnsRequired=true, before the request lands in the approvals queue. The hook receives the same scope (workspace_id,crew_id,agent_id,mission_id), the credential ID, and theintentstring. Use this to forward HITL notifications to Slack / PagerDuty / your own pager-of-choice. -
on_guardrail_triggered— fires from Lookout’sInputGuardon every finding (block, sanitize, AND log mode — log isn’t “guard disabled,” it’s observability + alerting without breaking the call). Wired via aGuardListenercontext callback ininternal/pipeline/runner_llm.goso the integration path stays decoupled (lookout has no dep on the hooks package). Payload:Severity on theHookEventContextis the finding’s severity (low|medium|high|critical) so a webhook can throttle by criticality. Dispatch failures are logged but never change the guard’s verdict — a blocked call stays blocked even if the alert channel is down. -
pre_task_delegation,post_task_delegation—AssignmentHandler.runAssignment(internal/api/assignments_run.go), the one function every delegation door (the sidecar’s/assign, its dispatch-pump retry, the mission engine’sDispatchAssignment, and@mention’sDispatchMention) converges on before a delegated task runs.pre_task_delegationis blocking-capable and fires before anything is spent on the run;post_task_delegationfires once the hand-off is journaled, fire-and-forget. -
post_tool_call—postToolCallObserver.Observe(internal/server/post_tool_call_adapter.go), the same observation the built-in F4.2 behavior monitor rides, but dispatched independent of that monitor’s governance/sampling gate: a workspace with the watchdog OFF (the default) still gets its own registeredpost_tool_callhooks on every observed call. -
pre_llm_call,post_llm_call— a new outermost layer (hooksCaller) inllm.Middleware’s caller chain (internal/llm/middleware.go), wrapping everyCompleteandStreamcall ahead of telemetry, paymaster, and lookout.pre_llm_callis blocking-capable and can refuse a call before any of those layers spend anything on it. -
pre_memory_write,post_memory_write—Consolidator.Run(internal/consolidate/consolidator.go), around the canonicalappendRuleswrite that lands an LLM-summarized batch of “learned rules” into a crew’slearned-*.mdwith no human in the loop. This is the automated half of memory writing, not an agent’s ownmemory.writetool call — that path executes inside the sidecar container, which has no database connectionhooks.Dispatchcould use, so it isn’t covered (see the FAQ below).pre_memory_writeis blocking-capable — the seam a future write-approval gate for agent memory would use. -
pre_peer_conversation,post_peer_conversation—QueryHandler.Create/finishQuery(internal/api/query_handler.go), the single entry point everycurl localhost:9119/querypeer question converges on.pre_peer_conversationis blocking-capable and fires before thepeer_conversationsrow exists. -
on_budget_exceeded—paymaster.Enforce(internal/paymaster/budgets.go), fired alongside thebudget.exceededjournal entry whenever a hard- or tiered-mode budget check blocks a call. Not (yet) fired fromEnforceQuota’s provider-quota-exhaustion path (the 429 case) — that would need a*sql.DBthreaded into a function that doesn’t currently take one.Debounced per breach, not per call.Enforceruns on every LLM call, butpaymaster.announceBudgetBreachtracks whether a given budget’s breach has already been announced for its current period, so the hook fires once when a budget first goes over, not again on later calls made while it stays over. It fires again once the window rolls (the same boundary spend-summing uses) or once the budget’s limit is raised and breached again. Two different budgets breached by the same call still each get their own dispatch, same ason_approval_requestedandon_guardrail_triggeredfiring once per triggering condition. The debounce state is in-memory: acrewshipdrestart can re-announce one already-seen breach.
Why isn’t there a hook for an agent’s own memory write?
memory.write /
memory.append_daily (the MCP tools an agent calls) execute inside the
sidecar process running in the agent’s container, which has no database
connection — hooks.Dispatch needs one to look up registered hooks. Wiring
it would mean adding new plumbing (a DB connection in the sidecar, or a
callback endpoint to crewshipd) rather than calling hooks.Dispatch from an
existing call site, which is out of scope for a 1.0-stabilizing hooks
surface. pre_memory_write / post_memory_write cover the other memory
write this platform makes without a human approving it — the background
consolidator’s automated learned-*.md append — which is arguably the more
valuable one to gate.Two other writes to that same canonical file are also uncovered, both by
design: the consolidator’s proposal path (proposal_mode, which stages
rules under .proposed/ instead of appending) and
consolidate.ApproveProposal, which merges an approved proposal in. Both
already have a human in the loop, which is what pre_memory_write exists to
add — a hook there would gate a decision an operator has already made.AllEvents in types.go, add the
hooks.Dispatch call site that fires it, and add coverage in
hooks_test.go. TestEveryOfferedEventHasADispatchSite fails CI if the
first two ever come apart.
Handler kinds
shell
Runs a command viaexec.CommandContext under the host shell — sh -c on Linux/macOS, cmd.exe /c on Windows (write the command line in cmd syntax there). The environment is sanitized to a pinned system PATH (plus SystemRoot/ComSpec on Windows) with the event context added on top. Stdout is captured as Result.Payload.
Every variable below is always set, empty when the event carries no value for it — so a command can read them unconditionally without set -u tripping:
Shell hooks require
OWNER role at registration time — an ADMIN cannot create a shell hook because arbitrary code execution on the host is a privilege escalation vector. The store enforces this via a register-time argument, not a row property:
allowedShell is the trailing boolean argument to hooks.Register (internal/hooks/store.go:22); the hooks_config table has no allowed_shell column. Callers that resolve role lazily (HTTP handlers, admin CLI) pass true only after confirming the request originated from an OWNER session.
The same gate applies on update: PATCH /api/v1/hooks/{id} refuses an ADMIN who converts an existing http hook into a shell hook, and refuses an ADMIN editing a hook that is already shell — editing the command is editing what runs on the host, whether or not the handler_kind field is in the request body.
http
POSTs the event context as JSON tohandler_config.url. Supports HMAC signing via handler_config.hmac_secret (header X-Crewship-Signature: sha256=<hex>). Timeout defaults to 30s, overridable via handler_config.timeout_secs.
subagent
Dispatches to an LLM subagent via the orchestrator. Not wired by default — the orchestrator registers a subagent handler at startup (RegisterSubagentHandler) that the dispatcher looks up.
If a subagent hook fires without a handler registered, Dispatch returns ErrSubagentHandlerNotConfigured.
Blocking vs non-blocking
Errors from blocking handlers are logged but do NOT short-circuit — a buggy webhook cannot wedge the platform. Only explicit
OutcomeBlock blocks.
Matcher
All fields optional and AND-combined. Empty matcher matches every event.Registration
Register a hook withcrewship hooks create (or POST /api/v1/hooks). Registration requires OWNER or ADMIN; --handler shell requires OWNER.
--disabled.
Earlier releases had no create endpoint — registration was a Go-only operation, which meant the dispatcher and all three handlers were unreachable from a running deployment.
hooks.Register is still the single write chokepoint; the API is now a caller of it rather than a replacement for it.Event names are validated on write
hooks_config has a CHECK constraint on handler_kind but not on event. A misspelled event therefore used to insert cleanly, never match ListByEvent’s predicate, and silently never fire — the hook listed, toggled, and looked healthy. hooks.Register now rejects any event outside the fourteen above (including pre_tool_call, which is no longer one of them), and the error message enumerates the legal values. The CLI checks the same list client-side before it sends anything.
hooks.Update applies the same check, but only when the write actually changes event. It re-reads the row’s current event from the database and compares — it does not trust the caller to say whether event was touched. A PATCH that omits event, or sends the value the row already has, skips event validation entirely, which is what keeps a legacy pre_tool_call row editable for every other field instead of frozen behind a permanent unknown event error. An update that changes event to something outside the fourteen is rejected exactly as Register would reject it. (Pinned by TestUpdate_LegacyEventRowStaysEditableForOtherFields and TestUpdate_LegacyEventRowRejectsChangingToAnotherUnknownEvent in internal/hooks/store_write_test.go.)
Because that read and the UPDATE are two statements, the UPDATE also carries AND event = <the value just read>: if another writer changes the row’s event in between, this update matches no rows and returns “not found” rather than writing a stale (and unvalidated) event back over the new one.
--blocking is only accepted on gate-capable events
blocking: true is a promise the call site has to be able to keep. Only the pre_* events whose dispatch site can still cancel the operation honour it — pre_task_delegation, pre_agent_start, pre_llm_call, pre_memory_write, pre_peer_conversation (Event.SupportsBlocking() in types.go). post_* and on_* events are observations: their dispatch site has already done the thing, so a synchronous wait would only add latency and a Block outcome would arrive too late to undo anything. Register/Update reject blocking: true on those with hooks: event cannot block. The CLI catches it client-side only when the same command also supplies --event — buildHookBody has no event to check against otherwise, so crewship hooks update <id> --blocking is sent and refused by the server against the event the row already has. Rows created before this check existed may still carry blocking = 1 on an observation event; they keep that value for display, but Dispatch demotes them to non-blocking rather than pretending the gate is real.
Endpoints
GET /api/v1/hooks[?crew_id=...]— list registered hooks (workspace-scoped).POST /api/v1/hooks— register a hook. OWNER/ADMIN; shell handlers OWNER only.PATCH /api/v1/hooks/{id}— partial update. Same roles.DELETE /api/v1/hooks/{id}— remove a hook. Same roles.POST /api/v1/hooks/{id}/enable— OWNER/ADMIN only.POST /api/v1/hooks/{id}/disable— OWNER/ADMIN only.
system.hook_toggled with the actor’s user ID. Create / update / delete write an audit_logs row (hook.create, hook.update, hook.delete) carrying the event, handler kind, and crew scope, so the audit trail captures who registered or removed which hook — the record that matters most for a shell hook, which is unrecoverable after deletion.
See Hooks API for schemas.
CLI
crewship hook (singular) is an alias for the whole group. Full reference: crewship hooks.
Web UI
Settings → Workspace → Lifecycle hooks (/settings?tab=hooks) is the read-only
registry: one row per hook with its event, handler kind, target, crew scope, last
result and enable switch.
- Every workspace member sees the list.
GET /api/v1/hooksis open to any authenticated member, and a member asking “why was my agent’s memory write refused” needs exactly this list. The switch isroleManage, so it renders for OWNER and ADMIN only. - The shell command is shown in full, matching what the API already returns
to every member and what
crewship hooks listprints. If a command may carry a secret, redact it server-side rather than in the browser — masking a value that is already on the wire protects nobody. - Last result comes from the journal, not from a new endpoint: the screen
reads
GET /api/v1/journal?entry_type=hook.fired,hook.blockedand keeps the newest entry perpayload.hook_id. blockingis read off the event, not off the row. A post- or on-event may carryblocking: truein the database; the column showsn/athere, because the dispatcher never asks those handlers for a veto.pre_tool_callrows are marked retired and explained, rather than listed as if they work. See Coverage status.
Journal entries
hook.fired— every dispatch lands this. Severity escalates towarnon non-pass outcomes.hook.blocked— separate entry so UI filters for “what blocked” don’t have to parse payloads.system.hook_toggled— admin enabled/disabled a hook.
hook_id, handler_kind, outcome, latency_ms, blocking, and the handler’s response when non-empty.
Gotchas
More edge cases
More edge cases
- Non-blocking goroutines use
context.Background(). Cancelling the request that triggered the dispatch does NOT cancel the hook; per-handler timeouts bound runtime. If a webhook hangs, the goroutine times out but is not killable from the caller. - Event names are stable. Renaming requires a migration on
hooks_config.event— breaks every existing registration. - Matcher regex compile is cached forever. A bad regex is cached as a nil sentinel; subsequent calls skip it silently. If a hook never fires, check the logs for
hooks: compile regex failed.
Related
- Harbormaster — emits
on_approval_requested. - Paymaster — emits
on_budget_exceededfromEnforce. - Lookout — emits
on_guardrail_triggeredvia the GuardListener context callback. - Crew Journal —
hook.fired,hook.blocked,system.hook_toggled. crewship hooks, Hooks API.- Settings → Workspace → Lifecycle hooks (
/settings?tab=hooks) — the registry in the browser.