> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Lifecycle Hooks

> Shell, HTTP, or subagent callbacks that fire on platform lifecycle events. Blocking and non-blocking modes supported.

# 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):

| 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 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](/guides/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](/guides/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:

  ```json theme={null}
  {
    "direction": "input",
    "kind": "role_override",        // role_override | system_prompt_leak | jailbreak | zero_width_unicode | rtl_override_unicode | lakera_detected
    "detail": "instruction-override request",
    "matched": "ignore previous instructions",
    "pipeline_id": "...",
    "pipeline_run": "...",
    "step_id": "...",
    "agent_slug": "..."
  }
  ```

  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.

<Accordion title="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 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.
</Accordion>

When adding a new dispatch site, also add a row to `AllEvents` in `types.go` and test coverage in `hooks_test.go`.

## Handler kinds

```go theme={null}
const (
    HandlerKindShell    HandlerKind = "shell"
    HandlerKindHTTP     HandlerKind = "http"
    HandlerKindSubagent HandlerKind = "subagent"
)
```

### shell

Runs a command via `exec.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:

```go theme={null}
const allowedShell = true // pass true only when the caller is confirmed OWNER
_, err := hooks.Register(ctx, db, h, allowedShell)
// returns hooks.ErrShellHookNotAllowed if h.HandlerKind == "shell" && !allowedShell
```

`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 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

```go theme={null}
type Hook struct {
    ...
    Blocking bool
}
```

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

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.

| 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 via `hooks.Register(ctx, db, Hook{...})`, typically from a workspace seed or a dedicated provisioning script. The API surface is strictly read + enable/disable.

```go theme={null}
_, err := hooks.Register(ctx, db, hooks.Hook{
    WorkspaceID: ws,
    CrewID:      crew, // empty = workspace-wide
    Event:       hooks.EventOnApprovalRequested,
    Matcher:     hooks.Matcher{Severities: []string{"high", "critical"}},
    HandlerKind: hooks.HandlerKindHTTP,
    HandlerConfig: map[string]any{
        "url":    "https://hooks.slack.com/services/...",
        "method": "POST",
    },
    Blocking: false,
    Enabled:  true,
    CreatedBy: userID,
}, true /* allowedShell — only true for confirmed OWNER */)
```

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

Toggling emits `system.hook_toggled` with the actor's user ID so the audit trail captures who flipped which hook when.

See [Hooks API](/api-reference/hooks) for schemas.

## CLI

```bash theme={null}
crewship hooks list
crewship hooks list --crew backend-team
crewship hooks enable hk_abc
crewship hooks disable hk_abc
```

Full reference: [`crewship hooks`](/cli/hooks). No `register` subcommand -- config-time 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

<Warning>
  **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.
</Warning>

<Accordion title="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`.
</Accordion>

## Related

* [Harbormaster](/guides/harbormaster) -- emits `on_approval_requested`.
* [Paymaster](/guides/paymaster) -- future `on_budget_exceeded` source.
* [Lookout](/guides/lookout) -- emits `on_guardrail_triggered` via the GuardListener context callback.
* [Crew Journal](/guides/crew-journal) -- `hook.fired`, `hook.blocked`, `system.hook_toggled`.
* [`crewship hooks`](/cli/hooks), [Hooks API](/api-reference/hooks).
