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 (15 events currently):
Coverage status (as of PR #210)
Currently dispatched (call sites wired):-
pre_agent_start,post_agent_stop— orchestrator agent lifecycle. Both payloads carrymission_idsince PR #210 so 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. PR #210 finally wired this dispatch site — earlier docs claimed it fired but the call site was missing. -
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.
Defined but not yet dispatched
Defined but not yet dispatched
Add a
hooks.Dispatch call at the relevant point when the corresponding platform path wires through:pre_task_delegation,post_task_delegation— hook into the assignment path (orchestrator.RunAgentForAssignment).pre_tool_call,post_tool_call— needs orchestrator tool-call interception.pre_llm_call,post_llm_call— fits cleanly intollm.Middleware.pre_memory_write,post_memory_write— memory consolidation path.pre_peer_conversation,post_peer_conversation— peer bridge.on_budget_exceeded— Paymaster already emits journal entries; hooks are the natural next step.
AllEvents in types.go and test coverage in hooks_test.go.
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). Event context is passed as env vars (CREWSHIP_EVENT, CREWSHIP_AGENT_ID, CREWSHIP_MISSION_ID, CREWSHIP_PAYLOAD, …); the environment is otherwise sanitized to a pinned system PATH (plus SystemRoot/ComSpec on Windows). Stdout is captured as Result.Payload.
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 and hooks.Update now reject any event outside the fifteen above, and the error message enumerates the legal values. The CLI checks the same list client-side before it sends anything.
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.
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 — future
on_budget_exceededsource. - Lookout — emits
on_guardrail_triggeredvia the GuardListener context callback. - Crew Journal —
hook.fired,hook.blocked,system.hook_toggled. crewship hooks, Hooks API.