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):
| Bucket | Events |
|---|---|
| Task delegation | pre_task_delegation, post_task_delegation |
| Agent lifecycle | pre_agent_start, post_agent_stop |
| Tool calls | pre_tool_call, post_tool_call |
| LLM calls | pre_llm_call, post_llm_call |
| Memory writes | pre_memory_write, post_memory_write |
| Peer conversations | pre_peer_conversation, post_peer_conversation |
| Policy | on_approval_requested, on_budget_exceeded, on_guardrail_triggered |
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. Event context is passed as env vars (CREWSHIP_EVENT, CREWSHIP_AGENT_ID, CREWSHIP_MISSION_ID, CREWSHIP_PAYLOAD, …). 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.
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
| Blocking | Dispatcher behaviour |
|---|---|
true | Sequential, same goroutine. A OutcomeBlock result short-circuits with *BlockedError — the caller uses errors.As to recover and abort the triggering operation. |
false | Fire-and-forget goroutine with context.Background(). OutcomeBlock is downgraded to a hook.blocked journal entry for audit; the triggering operation proceeds. |
OutcomeBlock blocks.
Matcher
All fields optional and AND-combined. Empty matcher matches every event.| Field | Semantics |
|---|---|
tools | Regex list against tool name (tool events only). Any-match. |
agent_ids | Exact match. |
crew_ids | Exact match; layered on top of row-level crew_id. |
severities | Exact match for severity-bearing events. |
when | Reserved for future CEL/expr predicate. Ignored today. |
Registration
There is no runtime create/delete endpoint by design. Hooks are registered at config time viahooks.Register(ctx, db, Hook{...}), typically from a workspace seed or a dedicated provisioning script. The API surface is strictly read + enable/disable.
Endpoints
GET /api/v1/hooks[?crew_id=...]— list registered hooks (workspace-scoped).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 so the audit trail captures who flipped which hook when.
See Hooks API for schemas.
CLI
crewship hooks. No register subcommand — config-time only.
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.