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 (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 carry mission_id since PR #210 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. PR #210 finally wired this dispatch site — earlier docs claimed it fired but the call site was missing.
  • 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.
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 into llm.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.
When adding a new dispatch site, also add a row to AllEvents in types.go and test coverage in hooks_test.go.

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). 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 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 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.
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.

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.