Skip to main content

Automations

An automation is one rule: when this kind of thing happens in this workspace, run that routine. It watches a single journal event type, evaluates a predicate against each entry, and parks a debounced run of a routine.
That is the whole feature. It exists because until now every trigger in Crewship was hard-coded: a schedule could start a routine, a webhook could start a routine, but “an issue changed status” could not, and there was no place to say so.

What an automation can and cannot do

An automation can only enqueue — or, for the one rule type nothing but Pages writes today, open an issue. It never executes a routine inline and holds no veto over anything. That is a deliberate boundary, not a missing feature. Automations are matched on the journal write path — the moment an event durably commits — so anything they did synchronously would be latency added to every write in the product. Matching is in-memory; the only thing that reaches the database is a single row parked in the deferred-run queue, and even that happens on a background flush rather than inline.
Automations are not hooks. A hook is an intercept: crew-scoped, blocking, and able to refuse the thing it fired on. An automation is workspace-scoped, non-blocking, and reacts after the fact. They look similar on paper and guarantee opposite things, which is why they are separate.

Anatomy of a rule

Choosing an event type

There is no wildcard by design: a rule that fires on “anything” is a support ticket waiting to happen. Pick the one type you mean, and confirm it exists before you save:
event_type is checked against the journal’s own closed registry (journal.AllEntryTypes, generated by scanning every journal.EntryType value declared or used anywhere under internal/ and cmd/ — currently 140 types) at save time, on automation create and automation update alike. The registry is not only internal/journal/types.go’s const block: about a dozen entry types are declared ad hoc in the packages that emit them (a page.* webhook type declared next to its handler, for instance), using shapes the scanner recognises but a reader skimming types.go alone would not find — see internal/journalgen’s doc comment for exactly which shapes. A value that is not in the registry is rejected with a 400 naming real alternatives — types sharing the same namespace as the typo when there are any, otherwise a handful of examples — so a typo can no longer produce a rule that is saved, listed, and never fires. This closed the gap: previously the API accepted anything merely SHAPED like an entry type (word.word), which a typo like mission.staus_change satisfies just as well as the real thing. The guarantee is about the event type, not the whole rule: a rule can still be saved with a matcher.payload_equals key its event type’s real emitter never writes, for any event type not covered by the curated map below (see “Payload key validation”). And a PATCH only re-validates the fields it actually changes — PATCH {"enabled": false} on a rule whose target routine has since been deleted still succeeds, the same way ListActive tolerates a dangling routine reference elsewhere; only a PATCH that itself sets event_type, matcher, or action.routine_slug is checked against that new value.

The matcher

Every predicate you set must be satisfied; the ones you leave out are “don’t care”. Setting none means every entry of that type matches. --payload-equals parses its value as JSON when it can, so count=3 matches the number 3 and action=status_changed matches the string "status_changed".
--payload-equals used to have the same silent-failure mode as a typo’d --event, and a nastier one: a key no emitter writes was accepted, and the rule then matched nothing, forever, with no error anywhere. That is now checked at save time for the event types listed below — read the “Payload key validation” section before relying on it for a type that isn’t one of them. Either way, read one real entry before writing the predicate:
and match on a key you can see in its payload.

Payload key validation

matcher.payload_equals keys are checked against a curated, incomplete map of event type → known payload keys (automation.KnownPayloadKeys in internal/automation/payload_schema.go), built by reading each listed type’s real emitter — not generated, and not exhaustive. A key for one of the mapped types below that its emitter does not write is rejected at save time, naming the keys that are actually valid. mission.comment is deliberately absent from this table even though it is a real, frequently-matched type: it has two emitters with entirely disjoint payload shapes (an issue-comment mirror that writes action/details, and a mission-comment mirror that writes comment_id/author_name/body/etc., never action), so no single key set is correct for it — rules on mission.comment get no payload-key check, same as any other uncatalogued type. An event type not on this list gets no payload-key check at all — a key against, say, run.completed or pipeline.run.failed is accepted unconditionally, exactly as before this change. This is a stated limitation, not an oversight: no declared payload-schema registry exists anywhere in the codebase (payloads are assembled at ~130 call sites, many through helper functions several frames from the journal write, which a static scan cannot reliably see through without either missing real keys or — worse — rejecting a valid one). Extending this table means reading the real emitter for the type you need and adding it to KnownPayloadKeys; automation preview remains the general answer for any event type, mapped or not — it replays real history against your matcher and tells you whether it would ever have matched.

What mission.status_change actually carries

Every issue event goes through one emitter. It writes two keys on every event, plus two more on a status transition: So “fire when an issue moves to DONE” is one predicate:
from and to appear only on a transition. A key that were always present and sometimes empty would be a predicate that silently matched nothing, which is the failure automation preview exists to surface. To react to any status change rather than one target, match the action and let the routine decide:
Some actions are precise enough to match on their own — review_approved is one thing and one thing only:
Note that mission.status_change is the CATCH-ALL issue entry type, not “status changed specifically” — created, assignee_changed, commented and mentioned have their own entry types, everything else lands here. That is why the action predicate is usually needed and not redundant.

Rules a Page wrote

A page panel’s wake: gate compiles to a rule in this same table, named page <slug>/<panel> wake <n>, with action_kind: issue. They appear in crewship automation list like any other rule, which is the point — a gate you cannot see is a gate you cannot debug. They are derived state: the page spec owns them, every save of that page rewrites them, and deleting the page deletes them. Disabling or editing one here does not stick. Remove the gate from the page instead. Their event type is page.panel.updated, which every accepted panel push emits, and it carries: page.panel.updated is not in the payload-key validation table above — wake_<n> is a family of keys, not a fixed name, and the curated schema only covers fixed key sets. A --payload-equals against this type is accepted without a key check; automation preview is the way to confirm it actually matches.

Inputs and the event namespace

Input values are rendered with the same template renderer routine steps use, against the entry that triggered the rule: An unresolvable reference renders empty, exactly as it does inside a routine.

Burst control

Two independent controls, and they do different jobs. debounce_seconds collapses a storm into one run. Two hundred status changes on the same issue inside the debounce window produce a single run carrying the most recent event’s inputs, not two hundred runs. The parked run also has a ceiling — debounce_seconds × 10 from the first match — so a stream of events that never stops still fires at the ceiling instead of being pushed out forever, and the next match after it starts a fresh run. Events collapse together only when they are about the same subject. The subject is the most specific identity the entry carries: the issue, else the run, else the agent, else the crew. So fifty run.failed entries for one run are one triage run, but two different runs failing inside the window are two — coalescing keeps the last event’s inputs, and folding unrelated subjects into one run would silently act on whichever arrived last. An entry that carries none of those identities is genuinely workspace-scoped and collapses to the rule itself. max_per_hour caps how many runs the rule may cause per rolling hour. It is charged per run, not per matched event — a burst that coalesces into one run costs one unit. Over the cap, matches are dropped and exactly one automation.throttled entry is written to the journal for that hour:
One per hour, not one per drop. A rule that trips its cap ten thousand times must not write ten thousand rows saying so.
max_per_hour is a burst brake, not a quota. The counter is held in the running server’s memory, so restarting the server clears it — a server that restarts every ten minutes has no effective hourly cap.Use it to stop a runaway rule. Do not use it where the number itself has to hold, such as billing or a contractual limit.
Enqueue failure visibility. A matched, coalesced rule that reaches the front of the queue and then fails to park its run — a database write error, not a policy refusal — writes an automation.enqueue_failed journal entry every time:
A single failure is treated as noise-tolerant (a momentary write blip) and is not surfaced beyond the journal entry. Once the same rule fails to enqueue 3 times in a row, a MANAGER inbox card is raised; further failures past the third do not raise additional cards, and a later successful enqueue resets the streak so a future run of failures can alert again. This is separate from automation.depth_exceeded (a refused composition hop) and automation.throttled (the hourly cap) — both of those are the rule deliberately declining to fire; automation.enqueue_failed is the rule trying to fire and the write failing underneath it.

Where rules show up in the UI

There is no automation management screen — the CLI above is the whole write surface — but the two pages a rule affects both say so, read-only:
  • A routine’s detail page (/routines) shows N automations beside its status pills whenever a rule targets it, and lists them under Triggers → Automations: rule name, the event type it watches, and whether it is armed. A disabled rule is shown greyed rather than hidden — a rule that is switched off is usually the answer to “why did nothing happen”.
  • An issue’s detail page (/issues) grows an Automations card listing the rules an event from that issue could set off.
Both are absent when nothing applies. A routine no rule targets, and an issue no rule watches, show nothing extra at all.
The issue card lists rules that could fire, not rules that will. Only mission_ids and crew_ids can be decided from an issue; agent_ids, severities and payload_equals describe an event that has not happened yet and narrow further at match time. A rule excluded by mission_ids or crew_ids is never listed — that exclusion is provable.

Watching a rule work

Every run an automation causes carries the rule’s id and how many events folded into it in the run metadata, so a run can always explain why it exists.

Permissions

Reading the list is available to any workspace member — “what fires here” is the first thing anyone debugging an unexpected run needs. Creating, editing and deleting are ADMIN or OWNER: a rule grants autonomous routine execution across the workspace, on events its author may never produce themselves.

Deleting

Deletion is soft. The rule stops matching immediately and the row stays in the database, because a run it caused is partly explained by the rule that caused it — hard-deleting turns those runs into orphans.

API

PATCH is sparse: only the fields present in the body are written, so toggling enabled cannot clobber a matcher somebody edited a moment ago. Full flag reference: crewship automation.

Limits

  • One event type per rule. Compose several rules rather than asking for a wildcard.
  • The action kind is routine in this release. The stored shape leaves room for others; anything else is rejected at write time rather than silently accepted and never run.
  • A rule whose routine does not exist in the workspace is refused at create/update time, and one whose routine is deleted afterwards stops firing until the routine comes back.