Skip to main content

kind: Hook

What it is

kind: Hook is the toggle-only manifest kind. A Hook document flips the enabled boolean on a hook that already exists; it can never create a new hook. Hooks are part of the runtime control plane — they fire on lifecycle events (pre_tool_call, post_agent_stop, …) and can run shell commands, dispatch sub-agents, or call HTTP endpoints. Because that surface is sensitive (arbitrary shell, third-party network egress), registration is kept out of the manifest and behind an explicit role gate: crewship hooks create / POST /api/v1/hooks (OWNER or ADMIN; shell handlers OWNER only), or hooks.Register from Go. The manifest therefore exposes exactly one verb: toggle. If the hook does not exist server-side, crewship apply fails with hook "X" is not registered — register it in code first, which is the prompt to register it out-of-band and re-apply.
Note. Earlier revisions of this page said the manifest was toggle-only because there was no create endpoint at all. There is one now (POST /api/v1/hooks). The manifest is still toggle-only, but by choice rather than by absence — giving YAML create authority over shell handlers is a separate design question, unresolved. See “Why this kind is special” below.

YAML schema

There is no event, matcher, handler_kind, or handler_config field. Those live in code and are immutable from the manifest’s perspective.

Examples

Minimal — enable a single hook

Disable a hook (e.g. dev environment)

Multi-doc bundle — toggle several hooks at once

A single crewship apply -f over this file leaves the workspace’s hooks in exactly the declared state. Hooks already in the desired state report as unchanged.

CLI reference

Hooks have a pre-existing CLI surface — crewship hooks ... — for listing and toggling outside the manifest. The manifest path is the declarative complement; the imperative commands stay useful for break-glass / one-off toggles in production. crewship hooks create exists, but it is imperative and role-gated — it is not reachable through crewship apply. Applying a kind: Hook document for a hook that does not exist is still the error path; the CLI message tells the operator to register it first.

REST endpoint mapping

The manifest only consumes three REST routes:
  • GET /api/v1/hooks — list every registered hook (used by Plan + Export)
  • POST /api/v1/hooks/{id}/enable — toggle on
  • POST /api/v1/hooks/{id}/disable — toggle off
The DB columns on hooks_config that the manifest actually touches: Every other column (event, matcher, handler_kind, handler_config, blocking, crew_id, workspace_id, created_*, updated_*) is read-only from the manifest’s perspective and set when the developer registers the hook in code.

Validation rules

Static, performed by Validate before any network round-trip:
  • apiVersion must equal crewship/v1.
  • kind must equal "Hook".
  • metadata.slug must be set (non-blank). It is the hook id.
  • metadata.name must be set (non-blank).
  • spec.enabled is a boolean; YAML defaults to false when omitted.
The “hook actually exists on the server” check happens at Plan time (it requires a live HTTP call against /api/v1/hooks). A missing hook surfaces as a PlanItem with Action=Update whose Exec closure returns the registration error — that lets --dry-run report every missing hook in one pass instead of stopping at the first.

Apply behavior

Default mode (ApplyUpsert)

  • Declared enabled matches remote → Action=Unchanged (no network call).
  • Declared enabled differs from remote → Action=Update, POSTs to /api/v1/hooks/{id}/enable or /disable.
  • Hook does not exist on the server → Action=Update with an erroring Exec closure (hook "X" is not registered — register it in code first). Apply fails on that hook but the dry-run plan shows every drifted/missing hook so the operator gets the full picture in one pass.

ApplyStrict

No semantic difference for hooks — the strict-mode “fail if any slug already exists” rule only fires for create actions, and hooks never create. A declared hook that’s missing in the registry produces the same registration-error PlanItem in either mode.

ApplyReplace

Same plan as ApplyUpsert. The “replace = delete + create” pattern has no meaning for a kind the user cannot author, so ApplyReplace collapses to the default toggle path. (Trying to delete a hook via the manifest would silently un-register a code path; the design intentionally refuses.)

Round-trip via export

crewship export workspace emits one kind: Hook document per row in hooks_config. The slug is the hook’s id; the spec carries the current enabled state. metadata.description is synthesised from event + handler_kind (e.g. "pre_run shell hook") — the hooks_config table has no description column, so the export side manufactures one for human readability. The round-trip property is one-way:
  • apply → server state matches manifest.
  • export → apply → no-op (manifest matches server, every hook reports unchanged).
export is the way to capture the current toggle layout for source control. Diffing two exports reveals which hooks drifted between environments.

Why this kind is special

Most manifest kinds have full Create/Update/Delete authority. Hooks deliberately don’t, because:
  1. Shell hooks execute arbitrary commands. A hook registered in YAML would let any operator with manifest-apply rights smuggle shell commands into the supervisor — an obvious privilege escalation. POST /api/v1/hooks gates that on OWNER explicitly; crewship apply has no comparable per-document role check, so routing creation through it would launder an OWNER-only grant through a weaker gate.
  2. HTTP hooks egress sensitive workspace state. Same reasoning — any new HTTP destination needs to go through the egress-allowlist review in code.
  3. Hook matchers are coupled to internal event names. Letting the manifest define a matcher freezes the manifest schema to the internal event enum. Keeping matchers in code lets the event surface evolve without a breaking manifest version bump.
The manifest still owns the policy (“which hooks are on in this environment?”) which is the part operators actually need to control declaratively.

See also

  • internal/hooks/store.go — Go side of hook registration (hooks.Register).
  • internal/api/hooks_handler.go — REST handler the manifest calls.
  • Hooks operator guide — runtime semantics, matcher syntax, and handler kinds.
  • kind: TriageRule — also “rules in YAML”, but those are user-creatable because they only mutate workspace data, not the control plane.