Skip to main content

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 in internal/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 carry mission_id so a hook can scope its action to the running mission without a separate join.
  • on_approval_requested — fires when Harbormaster’s Gate returns Required=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 the intent string. Use this to forward HITL notifications to Slack / PagerDuty / your own pager-of-choice.
  • on_guardrail_triggered — fires from Lookout’s InputGuard on every finding (block, sanitize, AND log mode — log isn’t “guard disabled,” it’s observability + alerting without breaking the call). Wired via a GuardListener context callback in internal/pipeline/runner_llm.go so the integration path stays decoupled (lookout has no dep on the hooks package). Payload:
    Severity on the HookEventContext is 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’s DispatchAssignment, and @mention’s DispatchMention) converges on before a delegated task runs. pre_task_delegation is blocking-capable and fires before anything is spent on the run; post_task_delegation fires 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 registered post_tool_call hooks on every observed call.
  • pre_llm_call, post_llm_call — a new outermost layer (hooksCaller) in llm.Middleware’s caller chain (internal/llm/middleware.go), wrapping every Complete and Stream call ahead of telemetry, paymaster, and lookout. pre_llm_call is 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 canonical appendRules write that lands an LLM-summarized batch of “learned rules” into a crew’s learned-*.md with no human in the loop. This is the automated half of memory writing, not an agent’s own memory.write tool call — that path executes inside the sidecar container, which has no database connection hooks.Dispatch could use, so it isn’t covered (see the FAQ below). pre_memory_write is 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 every curl localhost:9119/query peer question converges on. pre_peer_conversation is blocking-capable and fires before the peer_conversations row exists.
  • on_budget_exceeded — paymaster.Enforce (internal/paymaster/budgets.go), fired alongside the budget.exceeded journal entry whenever a hard- or tiered-mode budget check blocks a call. Not (yet) fired from EnforceQuota’s provider-quota-exhaustion path (the 429 case) — that would need a *sql.DB threaded into a function that doesn’t currently take one.
    Debounced per breach, not per call. Enforce runs on every LLM call, but paymaster.announceBudgetBreach tracks 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 as on_approval_requested and on_guardrail_triggered firing once per triggering condition. The debounce state is in-memory: a crewshipd restart 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.
When adding a new event, add it to 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 via exec.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:
Quote "$CREWSHIP_PAYLOAD" (and every other variable above). An unquoted expansion is word-split and glob-expanded by the shell, which is both a correctness bug and an injection vector on a handler that runs as the crewship process on the host. hooks.Register lints for this and logs a warning, but it does not refuse the command.
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 to handler_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 with crewship hooks create (or POST /api/v1/hooks). Registration requires OWNER or ADMIN; --handler shell requires OWNER.
New hooks are enabled on creation unless you pass --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.
The equivalent in Go, for provisioning code that runs in-process:

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.
Toggling emits 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/hooks is open to any authenticated member, and a member asking “why was my agent’s memory write refused” needs exactly this list. The switch is roleManage, 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 list prints. 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.blocked and keeps the newest entry per payload.hook_id.
  • blocking is read off the event, not off the row. A post- or on-event may carry blocking: true in the database; the column shows n/a there, because the dispatcher never asks those handlers for a veto.
  • pre_tool_call rows are marked retired and explained, rather than listed as if they work. See Coverage status.
Registration, editing and a workspace-wide pause are not in the UI. The first two are a follow-up; the third has no endpoint — the API has per-hook enable and disable only.

Journal entries

  • hook.fired — every dispatch lands this. Severity escalates to warn on 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.
Payload includes hook_id, handler_kind, outcome, latency_ms, blocking, and the handler’s response when non-empty.

Gotchas

Shell hooks bypass container isolation. They run on the host with the privileges of the crewship process. Restrict to OWNER role and audit handler_config.command carefully.
  • 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.
  • Harbormaster — emits on_approval_requested.
  • Paymaster — emits on_budget_exceeded from Enforce.
  • Lookout — emits on_guardrail_triggered via 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.