All endpoints require authentication and are workspace-scoped. Every write (create / update / delete / enable / disable) requires
OWNER or ADMIN. Creating or editing a shell handler additionally requires OWNER — see the shell gate.Endpoints
Reading hooks
List the registered hooks for a workspace, optionally narrowed to one crew.List hooks
Response:
200 OK
Errors:
Writing hooks
Create a hook
OWNER or ADMIN. handler_kind: "shell" requires OWNER.
Body:
201 Created, body is the created hook in the same shape list returns per row.
Errors:
Writes an
audit_logs row with action hook.create.
Update a hook
OWNER or ADMIN. OWNER is required to set handler_kind: "shell" or to edit a hook that is already shell.
Semantics are patch, not put: only the fields present in the body change. An omitted field keeps its current value, so {"event": "post_tool_call"} does not clear the matcher, the blocking flag, or the enabled flag.
Accepts the same fields as create, all optional.
Response: 200 OK with the updated hook.
Errors: as create, plus 404 when the hook is outside your workspace (cross-tenant IDs are indistinguishable from missing ones).
Writes an audit_logs row with action hook.update.
Delete a hook
OWNER or ADMIN. The request has no body.
Response: 200 OK
401 unauthenticated, 403 for a non-OWNER/ADMIN, 404 when the hook is not in your workspace.
Deletion is permanent. To stop a hook firing while keeping its definition, use disable instead. Writes an audit_logs row with action hook.delete carrying the deleted hook’s event, handler kind, and crew scope — after the delete there is nothing left to reconstruct it from.
The shell gate
handler_kind: "shell" runs a command on the crewshipd host, outside container isolation, with the privileges of the crewship process. That is a strictly larger grant than the rest of the ADMIN surface, so it needs OWNER — on create and on update alike. An http hook PATCHed into a shell hook is the same grant by a longer road, and is refused for the same reason.
The gate is enforced twice on purpose: in the handler (which knows the caller’s role) and again in hooks.Register / hooks.Update (which is the only write path into hooks_config). Either alone would be sufficient; both means a future call site that forgets the check still cannot land a shell hook.
Toggling hooks
Flip a hook on or off. Both transitions emitsystem.hook_toggled into the journal with the actor’s user ID.
Enable
id is the hook ID. The request has no body.
Auth: OWNER or ADMIN only (403 otherwise). Enabling a hook can invoke shell commands, hit third-party webhooks, or dispatch subagents — the role bar matches that risk.
Response: 200 OK
Emits
system.hook_toggled into the journal with the actor’s user ID.
Disable
id is the hook ID. The request has no body.
Same auth rules, same response shape with "enabled": false.
Errors: 401 when unauthenticated, 403 for a non-OWNER/ADMIN,
404 when the hook is outside the workspace, and 500 on a database error.
Registration from Go
In-process provisioning code can skip HTTP and call the store directly. This is the same function the endpoints above call — there is one write path, not two:allowedShell bool argument is the gate on HandlerKindShell: pass false for any non-OWNER context — the function returns hooks.ErrShellHookNotAllowed if the hook is a shell kind and this flag is false. HTTP / subagent hooks are unaffected by the flag. Register also returns hooks.ErrUnknownEvent for an event outside the declared fifteen.
hooks.Update(ctx, db, workspaceID, hook, allowedShell) is the equivalent for edits. It takes the full desired hook (not a patch — merging is the HTTP layer’s job), pins id + workspace_id from its arguments so a struct naming another workspace cannot move the row, and never rewrites created_at / created_by.