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
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
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 onPOST /api/v1/hooks/{id}/disable— toggle off
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 byValidate before any network round-trip:
apiVersionmust equalcrewship/v1.kindmust equal"Hook".metadata.slugmust be set (non-blank). It is the hook id.metadata.namemust be set (non-blank).spec.enabledis a boolean; YAML defaults tofalsewhen omitted.
/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
enabledmatches remote →Action=Unchanged(no network call). - Declared
enableddiffers from remote →Action=Update, POSTs to/api/v1/hooks/{id}/enableor/disable. - Hook does not exist on the server →
Action=Updatewith 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 reportsunchanged).
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:- 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/hooksgates that on OWNER explicitly;crewship applyhas no comparable per-document role check, so routing creation through it would launder an OWNER-only grant through a weaker gate. - HTTP hooks egress sensitive workspace state. Same reasoning — any new HTTP destination needs to go through the egress-allowlist review in code.
- 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.
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.