What is a Routine?
A Routine is a declarative recipe for repeatable AI work. You author it once (or an agent authors it for you when it spots a repetitive pattern), it runs the same way every time, and it lives in your workspace as a reusable asset alongside crews, skills, and credentials. Each routine bundles:- A JSON DSL definition — inputs, outputs, ordered or DAG-structured steps, validation gates, declared egress, credential requirements
- Authorship metadata — which crew, which agent (or user), via which path
- Triggers — cron schedules and webhook tokens that fire it autonomously
- Run history — every invocation as an immutable journal trace with step-level events
- Versions — publishing creates an immutable version; rollback is one API call
The routines page
The explorer sidebar carries search, the status buckets and the routine list, as on every page; on a phone it opens over the main view. The main Routines list applies the same filters: each row carries the recipe’s name, purpose, step count, how it runs (manual or its plan), how its last run went and when. A run waiting for a decision sits above the list as a banner. Calendar and Recent runs are the other views. Missing descriptions are summarized from actual step actions; an unreadable recipe is shown as unavailable. Selecting a routine uses?slug= in the URL. Reload and browser history preserve
that selection. A selected run adds &run= and keeps its recorded recipe version.
The detail page
Steps use an explicit name when provided, otherwise a description of their action.
Stable IDs remain secondary technical identifiers. Empty schedules and results
collapse to a short row. The run page leads with its verdict, error and failed
step, and opens failed, current or waiting steps automatically. The result and
the next step come before run provenance and the shareable evidence, which stay
further down the same page. Missing evidence is distinguished from an empty
result. Stopping a run does not undo prior actions.
The overview pane and the header Activity menu load running and waiting runs
from their own list, so a long run stays visible however many runs started
after it. The run summary is computed from the 200 newest runs: when those do
not reach back seven days it reads newest N runs · since … instead of 7d.
A run whose result is partial or needs a person counts as finished, not as a
success.
A run detail’s recipe comes from the run’s own archived record — resolved by
content hash, then numeric version, never from the routine’s current head — so
the trigger, executed version and declared credential types it shows are what
ran, not what is saved now. Credential-type declarations are what the recipe
declared; per-run credential use is not recorded. Runs recorded before
verified provenance keep their invoking ids as recorded, without presenting
them as verified.
Waiting decisions appear in the run and the authenticated Inbox. Typed forms
use the same decision contract in both places. A routine proposed for review also
has an Inbox item showing its requested access and version changes. Review actions
remain subject to server permissions. Recurring and automation starts retain their
recorded provenance; the stored
triggered_via value alone does not distinguish them.
The icon and colour are independent metadata: changing them does not publish a
recipe version. Routine budgets remain guardrails, not invoices.
Edit and Test
New routine offers AI authoring, copying an existing recipe or writing one. These paths use the same durable draft. Edit is one scrolling document: identity, Inputs, steps and schedule. A selected step has Edit / Test tools. Code edits the same buffer, preserving unsupported keys and expressions. Publication changes is a collapsed review inside the document.- Save draft saves work without publishing or running it. A conflicting edit reports the conflict and preserves local work.
- Test routine checks the recipe, references and static prerequisites. It does not run agents, scripts or HTTP calls and cannot prove a real run will succeed.
- Test this step uses sample data. Transforms calculate a result; agent, HTTP and script steps require an explicit replacement output and make no external calls. Unsupported step types cannot execute through this test.
- Publish performs the required recipe test and publishes the draft after applicable risk review. It is one explicit action, separate from saving a draft.
- Run starts actual work. It can change data in connected systems and incur costs. With inputs, it first presents their typed form. A new run is recorded in History; an existing run retains its accepted snapshot after later edits.
fixture_test, test_run and dry_run API/CLI names retain their existing contracts.
Three authoring paths
Crewship is unusual among workflow systems because agents author routines, not just execute them. Three paths converge on the samepipelines table:
Agent
The most common path. An agent that’s solved a repetitive problem twice posts to
http://localhost:9119/pipelines/save from inside its container; the sidecar injects authorship and forwards to the main API. Next time [AVAILABLE ROUTINES] block in the system prompt advertises it to other crews.You can also just describe a routine in chat — “make a routine that summarizes yesterday’s commits and posts to Slack.” A crew Lead carries the bundled routine-author skill (an authoring playbook): it clarifies the essentials, grounds the DSL in the crew’s [CONNECTED INTEGRATIONS] and [AVAILABLE ROUTINES], writes and test-runs the routine, then tells you whether it went live or landed as a proposed routine for a Manager to approve (see Governance).UI
Open
/routines, choose New routine, then write or copy a recipe.
Edit the document or its Code view, use Save draft for unfinished work
and Publish to make it available. Existing routines use the same editor
through Edit. The server enforces permissions, draft revisions and the
publication test proof for both paths.CLI
Scaffold a starting file with
crewship routine init -o file.json (a minimal valid one-step skeleton) or clone an existing routine with crewship routine init --from <slug> -o file.json. Then crewship routine save --name "..." --definition file.json --author-crew <crew-slug-or-id>. The server validates the DSL on save (same gate as the UI). CI-friendly: validate offline first with crewship routine validate file.json, then save. Print the machine-readable authoring contract with crewship routine schema (wire it into an editor for autocomplete, or hand it to an agent authoring a routine).The routine editor uses the draft/test/publish flow. Its UI does not expose a
skip-test checkbox. The legacy
/save contract remains for compatible API/CLI
clients, including its separately permission-checked skip_test_gate option.Authoring with AI (one-shot)
Handing a routine to an LLM to author (Claude Code over the CLI, or an in-container agent) used to mean piecing the palette together from ~8 commands —routine schema for the DSL, crew config for the container, separate calls for integrations, agents, and runtimes — and still guessing which runtimes are actually wired. crewship routine capabilities <crew> collapses that into one discovery dump:
routine validate-clean DSL on the first try:
schema— the routine DSL JSON schema (the same bytes asroutine schema), nested inline so the whole contract travels in one response.container— the crew’s resolved devcontainer capabilities:datastores(host/port to declare underresources.datastores) and installed CLItools.integrations— connected integrations WITH their enabled tool names (GMAIL_FETCH_EMAIL, …), so the author references real tools, not guesses.agents— the crew’s agent slugs, whichagent_runsteps reference.runtimes— the truth about what’s wired in this build:code.wired(expr,cel) vscode.reserved_unwired(python/go/bash— legal names but no runner, so don’t use them), and thetype: scriptinterpreter inference table (.py → python3, …).
discover_capabilities MCP tool on the crewship-routines server (alongside save_routine / list_routines / run_routine) — scoped to its own crew, no arguments. Call it first when authoring so save_routine passes its test-run gate on the first attempt. The in-session [CONNECTED INTEGRATIONS] system-prompt block also now names each integration’s enabled tools, so an agent answering a live request knows exactly which tools to reach for.
Backed by GET /api/v1/crews/{crewId}/capabilities.
DSL anatomy
Minimal valid routine:YAML instead of JSON
crewship routine validate and crewship routine save --definition also accept YAML — sniffed from the file’s content, not its extension, and converted to canonical JSON before validation/upload. The same minimal routine, as YAML:
prompt via YAML’s literal block scalar (|) instead of a JSON string full of \n escapes — this is also why routine init’s scaffold used to smuggle its “run routine schema, replace agent_slug” pointer into the description field (JSON has no comment syntax); with YAML input that workaround is no longer necessary, though the JSON scaffold itself is unchanged. The API, the DB, and every other Parse call site still only ever see canonical JSON — YAML is purely a convenience at these two CLI entry points, not a second stored format.
Top-level fields
InputSpec
type is one of string | integer | number | boolean | array | object. min / max are *float64 so decimal constraints work for number types.
Template substitution
Anywhere a string is interpolated (prompt, http URL/body/headers, wait until, code, transform, conditionalif), placeholder {{ ... }} resolves against:
inputs.X— declared input valuesteps.Y.output— full text output of an earlier stepsteps.Y.output.path— JSON path into a step’s output (when output parses as JSON)env.AUTHOR_CREW_NAME/ etc. — read-only allowlist of execution contextsecrets.<type>— a workspace-vault credential, resolved at run time by type (see Secrets below)routine.state.<key>— durable cross-run state carried forward from a previous run (see Cross-run state below)
Secrets ({{ secrets.<type> }})
A code / script / notify / http step that needs an API key, token, or password references it as {{ secrets.<type> }} — never by pasting the value into an env: map (which is versioned, exported, and diffed in plaintext). At run time the placeholder resolves to the decrypted value of a matching ACTIVE credential in the workspace vault, resolved by type exactly like an http step’s credential_ref: workspace-scoped, author-crew isolated through credential_crews, newest-active-wins on rotation.
{{ secrets.stripe }} template), and it is scrubbed from the step’s downstream output, the run journal, script-audit command lines, notification bodies, and error messages — so an echoed or reflected secret shows up as [REDACTED:secret], not the real value. A type with no ACTIVE credential renders empty (like a missing input), so a public/optional call keeps working; to make an unresolvable credential a hard failure, declare it in credentials_required.
Save-time validation walks every template-bearing field and rejects placeholders that reference unknown inputs or unseen-yet steps — so a typo’d
{{ inputs.tyop }} fails at save, not at 3am in production.Cross-run state ({{ routine.state.<key> }})
run.metadata.* is per-run scratch — gone when the run ends. Cross-run state carries a value forward to the next run of the same routine, which is what every incremental / polling job needs: a watermark (“last processed id / timestamp”) so each run picks up where the last left off.
- Read with
{{ routine.state.<key> }}. A key that was never written renders empty (like a missing input). The value you read is the snapshot as of run start — it reflects what a prior run wrote, not a write earlier in this same run. - Write with a step’s
state_writebinding — a map ofkey → template, rendered after the step completes (so it can reference the step’s own output) and persisted for the next run:
(routine, schedule)); manual / webhook runs share a single default bucket per routine. It is durable — a value written before a restart is read after it — because it lives in the database, not memory. A wake gate probe reads the same bucket its main scheduled routine writes, so an agentless probe can gate on the last watermark.
State is a small key/value store for coordination values (cursors, timestamps, counts), not a data warehouse. Writes are best-effort — a failed state write logs a warning and never fails the step. Values are opaque strings; JSON-encode a structure yourself if you need one.
Inspecting and repairing state
A watermark can go wrong in a way that is silent and permanent: write a cursor that is too far ahead (a future timestamp, an id from the wrong source) and every later run finds nothing to do — and reports success.crewship routine state is
the operator surface for that.
state list shows every bucket by default — because state is isolated per
schedule, the stuck cursor is usually in a bucket you would not have guessed.
The UPDATED column is normally the tell: a cursor frozen three days ago next to
schedules that ran this morning.
Mutations are ADMIN-tier. A watermark governs what every future unattended
run does, so rewriting one carries the same blast radius as disabling the
routine. clear is deliberately bucket-scoped — there is no “wipe every
schedule” form, because that makes each schedule reprocess its whole backlog with
no undo. Prefer set to a known-good value when you can.
Full flags: crewship routine state.
Required integrations
A routine can DECLARE the third-party integrations (Composio connectors) it needs with the top-levelintegrations_required array, and the run path will
block a run when the executing crew hasn’t connected one of them. This
closes the “integration forgotten” gap — the same shape as egress_targets,
but for connectors instead of hosts.
- Declared is always allowed. Saving a routine that names an integration the crew lacks is fine — declaring is a contract, not a connection. Only the run enforces. (Save-time validation only checks the list is well-formed: non-empty slugs, lowercased/trimmed, within a sane count cap.)
- Enforced at run time. Before a run starts, Crewship resolves the
integrations the routine’s author crew has connected and compares them to
integrations_required. If any are missing the run is blocked with an RFC 7807 Problem Details response, HTTP 422, carrying a machine-readablemissing_integrations: string[]member and a humandetaillikeroutine requires integration "slack" not connected for crew "Marketing". The UI usesmissing_integrationsto render a Connect action. The run never starts — no tokens spent. - No-op fast path. An empty / absent
integrations_requireddoes zero resolution work — no overhead for routines that don’t use it. - Fail-open. If integration-availability resolution itself errors, the run is allowed (a warning is logged). A bug in resolution must never wedge every run of every routine — a forgotten integration is a soft, recoverable failure; a hard block on all runs would be a self-inflicted outage.
runis gated;dry_runis not. A liverunexecutes against the author crew’s agents, so it’s gated (fail fast rather than land an unrunnable routine). The internal save gate’s draft validation applies the same integration check.dry_runis a preview that invokes nothing, so it’s left ungated — it shows what the routine would need, even integrations the crew hasn’t connected yet.
Integration availability is resolved from the crew’s connected Composio
connectors (the MCP server rows the bind flow writes). Two limitations:
under the workspace default connector (every agent inherits all connected
apps), the gate treats integrations as available without enumerating them; and
resolution reflects what’s wired, not live connection health (a revoked
account still reads as available until its binding is removed).
Required credentials
A routine DECLARES the vault credentials its{{ secrets.<type> }} refs (and
http credential_refs) need with the top-level credentials_required array
of { "type": "...", "scope": "..." }. Like integrations_required, the type
is matched against the workspace vault by purpose, not by ID, so a
marketplace “stripe” template runs against any workspace holding a stripe
credential.
- Declared is always allowed. Saving/importing a routine that names a
credential the vault lacks is fine — you can connect it later. Save-time
validation only checks the list is well-formed (non-empty
type, deduped, within a count cap). - Enforced at run time. Before a live
runstarts, Crewship probes the author crew’s workspace vault for an ACTIVE credential of each declared type. If any are missing the run is blocked with an RFC 7807 Problem Details response, HTTP 422, carrying a machine-readablemissing_credentials: string[]member and a humandetail. No tokens spent, no half-run with an opaque auth failure deep in a step. - No-op fast path. An empty / absent
credentials_requireddoes zero probe work. - Unavailable is not available. A credential probe error (including an unavailable database) blocks dispatch with HTTP 503. Retry after the store recovers. A confirmed missing credential remains HTTP 422. In-process child calls and scheduled dispatch use the same blocking gate.
- Enforced wherever
integrations_requiredis. Liverun,run_batch, internal runs, and thetest_runsave-preview gate all check it — the same precondition boundary that already probes for required integrations and resources. Only the executor’s puredry_runmode — which invokes no steps and resolves no secrets — is exempt. Persisting a well-formed draft is still fine (see above); it’s running and the test-run preview that block until the credential is connected.
Capability manifest
Every routine has a derived capability manifest — the full “what this routine touches” blast radius. The detail API (GET .../routines/{id}) returns
it under a manifest member so the UI can render a data-flow diagram and
governance can reason about the whole footprint of a run, not just its visible
steps.
Most of the manifest is auto-derived from the DSL — you don’t declare it:
The walk covers routine-level (
before_all / after_all / on_failure) and
per-step (before / after) lifecycle hooks too — a capability hidden in an
on_failure hook is still part of the blast radius. Every list is deduped +
sorted and always rendered as [] (never null), so the diagram is stable.
The resources block
Two things can’t be inferred from the step graph: datastores a routine
reads/writes, and CLI tools/scripts it runs. In production, code steps
aren’t wired — agents run scripts (ansible, kubectl, …) via an agent_run
step that shells out, so static analysis can’t see them. Declare them so they
show up in the manifest:
datastores[].type— any string, but use the canonical vocabularyredis | postgres | mysql | mongodb | otherso it matches what the crew’s container catalog reports (the precondition gate comparestypecase-insensitively; a non-canonical type simply won’t match a crew resource).tools[].type— any string; canonical examplesansible | terraform | kubectl | bash | python | other. Same matching rule as datastores.name/noteare free-form and optional.
type, or more than 32 of either kind.
The run path enforces availability — see Required resources.
Required resources
Theresources block is a run-time precondition gate, the resource sibling
of Required integrations: it states what the routine
requires (datastores like Postgres/Redis, CLI tools like ansible), and the
run path checks it against what the executing crew’s container actually has
([CONTAINER RESOURCES] — the crew’s sidecar datastores + installed tools).
Semantics:
- Enforced at run time. Before a run starts, Crewship resolves the author
crew’s container resources and compares them to the declared
resources.datastores+resources.tools. If the routine requires a datastore or tool the crew doesn’t have, the run is blocked with an RFC 7807 Problem Details response, HTTP 422, carrying a machine-readablemissing_resources: [{ kind, type, name }]member (kindis"datastore"or"tool") and a humandetaillikeroutine needs datastore postgres, tool ansible, not available to crew "Ops". The run never starts — no tokens spent — until the resource is provisioned on the crew. - Matching. A required datastore is satisfied when the crew has a
datastore of the same engine
type(case-insensitive; the servicenameis advisory and not matched). A required tool is satisfied when the crew has an installed tool whose name matches the requiredtype(the tool’sname, e.g.deploy.yml, is the concrete artifact and isn’t matched against the container). - No-op fast path. A routine that declares no
resourcesblock does zero resolution work. Only the declared resources are gated —code-step runtimes folded into the manifest’stools(e.g.cel) are internal executor runtimes, not crew CLIs, and are never gated. - Fail-open. If the routine has no author crew, or resource resolution itself errors, the run is allowed (a warning is logged) — same reasoning as the integration gate: a resolver bug must never wedge every run.
runis gated;dry_runis not. (The internal save gate’s draft validation applies the same resource check.)
Agents already know what the container has
You don’t have to tell an agent that its crew runs Postgres or shipskubectl —
Crewship surfaces it automatically. Every agent’s system prompt now includes a
[CONTAINER RESOURCES] block listing the crew’s datastores (derived from the
crew’s sidecar services — a service named postgres is reachable at host
postgres on its declared port) and installed CLI tools (derived from the
crew’s devcontainer features + mise toolchain, e.g. ansible, kubectl, git,
python, node). The agent is instructed to use these directly instead of
probing or trying to install them. So when you author a routine that needs a
datastore, declare it under resources.datastores and connect via the host/port
the agent already sees in that block. The block is omitted entirely when a crew
has no services and no notable tools.
Step types
agent_run
complexity resolves through the workspace’s execution_tiers_json mapping into (adapter, model). With complexity: "fast", the agent’s CLI gets --model claude-haiku-4-5-20251001 (or whatever the workspace mapped fast to). model_override is the explicit pin that wins over complexity.
A busy agent is waited for. An agent runs one turn at a time. When the step’s agent is busy with a chat, an assignment or another routine, the step waits for it instead of failing, for up to its timeout_seconds (15 minutes when the step sets none) and until the run is stopped. Steps waiting for the same agent take it as it frees, in no guaranteed order. If the agent stays busy for the whole wait, the step fails with “agent … stayed busy for …”, and its retry policy and on_failure apply as usual. The wait holds the run and does not survive a server restart; the waiting time comes before, and does not shorten, the step’s own run time.
on_fail lives at the step level (not inside validation) and is one of escalate_tier | abort | retry_step — escalate_tier walks the fallback chain (e.g., Haiku → Sonnet → Opus) until validation passes or the chain exhausts. retry_step is sugar for the default retry policy (retry the step on a transient execution error); for the validation/rubric gate it behaves as escalate_tier.
Retry transient failures
retry is a per-step policy for execution errors — the runner failed before an output could even be validated (HTTP 5xx, a code-step timeout, a rate limit, a network blip). It is distinct from on_fail, which handles a bad output that failed its validation/rubric gate. Retries exhaust first; then on_fail engages.
retry_on sees three variables: error (the message string), transient (a bool from the built-in 429/5xx/timeout/net-blip classifier), and status (the HTTP status an http step failed with — parsed from its HTTP <code> error, 0 for any other error, so a stray number elsewhere in a message is never misread as a status). It must evaluate to bool. Examples: 'transient' (shorthand), 'status == 429', 'status >= 500', 'error.contains("429")', 'error.matches("(?i)timeout|5\\d\\d")'. A predicate that doesn’t compile — or doesn’t return a bool — is rejected at save time; if a stored routine somehow carries a broken one, the runtime fails safe and does not retry rather than retrying on every error.
An in-band agent failure — the agent CLI exited 0 and reported that its own turn failed — is never transient. It is the agent’s verdict on its work, not a transport fault, so a same-tier retry would repeat a deterministic failure and bill for every attempt; escalating the tier (on_fail: escalate_tier, the default) or failing fast is the useful response. The classifier decides this from the error’s identity, not its wording — a refusal that happens to read “I cannot process a list of 500 items” does not become transient because it contains 500. A retry_on predicate can still opt in explicitly with error.contains("agent reported a failed run") if a specific routine wants that.
The loop is cost-aware and predictive: after each failed attempt it estimates the next attempt’s cost from the running average, and if that would push the run’s accumulated cost past max_cost_usd it stops before spending into the red — surfacing the underlying failure (wrapped, not masked) rather than a bare budget error. Each retry emits a pipeline.step.retrying event (attempt N/M) so the recovery is visible in the run trace without the run going red.
on_fail: retry_step with no explicit retry: block is shorthand for the default policy: 3 attempts, exponential backoff 1s→60s, full jitter, retry any error.
call_pipeline
integer, number, boolean, array or object type. For example,
"count": "{{ inputs.count }}" forwards numeric 0 to a child declaring an
integer count; it does not forward text "0". Missing references, incompatible
types and invalid choices fail before the child dispatches work. Optional null
stays null. JSON step outputs may include the same markdown fences accepted by
transform steps.
String and undeclared target inputs keep text interpolation. Mixed text such as
"count={{ inputs.count }}" remains text. Literal objects and lists pass through
unchanged; templates inside them are not recursively evaluated. Use Code for
advanced bindings; the visual editor can replace the source of an existing
child input without changing its other bindings.
A called routine faces the same dispatch gates as one run directly: its
author crew must have the integrations, datastores/tools and credentials the
called routine declares. If it does not, the call_pipeline step fails with
the same sentence the direct run would have returned (routine "x": routine requires credential of type "stripe" not present in the vault for crew "y")
— calling a routine is not a way around a precondition it does not meet.
dry_run is exempt, as it is on the direct path: it touches no vault.
Chain depth — the composition budget
Nesting depth is not the only way a routine can call itself. A routine can act on an issue, that change can fire an automation, and the automation can run the routine again — a loop that never appears on any one run’s call stack, because each hop is a new top-level run. Every composed edge therefore also spends from a chain depth budget carried on the run itself (chain_depth on pipeline_runs, alongside chain_origin —
the id of whatever started the chain):
- A run a person started is depth 0.
- A routine called by
call_pipelineis parent + 1. - A run fired by an automation reacting to an event is that event’s run + 1.
automation.depth_exceeded entry lands in the activity feed naming the
edge that was cut, the depth, and the chain origin — so a loop shows up as a
readable event rather than as a routine that “sometimes doesn’t run”.
The number is deliberately small. It can be raised later; it cannot be lowered
once people have built chains that depend on it.
http
http step (and http hook) passes two host
gates at run time, checked pre-flight AND on every redirect hop
(CheckRedirect callback):
-
Routine layer —
egress_targets. When the routine declaresegress_targets, the request host must be one of the declared hosts or a subdomain of one (api.x.commatches targetx.com;evilx.comdoes not). A routine that declares noegress_targetsis unrestricted at this layer — backward-compatible with every routine that predates the field. The SSRF guard (private/link-local IPs, DNS-rebind-safe dialing) applies regardless. -
Crew layer — network policy. The authoring crew’s network policy
(
network_mode+ allowed domains — the same dial that governs the crew’s agent containers) also applies to directhttpsteps. Arestrictedcrew’s routines can only reach the crew’s allowed domains (exact host match, same as the container proxy). A crew onfreemode is not unconditionally open at this layer: anhttpstep that does not declareegress_targetsis still held to the same floorrestrictedmode enforces — the sidecar’s default LLM/CLI provider domains only. This closes an SSRF gap where anycreate-role member (or a webhook payload driving{{ inputs.url }}) could otherwise point an undeclaredhttpstep at an arbitrary public host just because the crew allows its agents free egress. Declaringegress_targetson the routine is the escape hatch — it bypasses this floor and the routine’s own allowlist governs instead (private/link-local IPs still blocked by the SSRF guard regardless). Webhook-triggered runs go further: they’re always held to therestrictedfloor, regardless of the crew’s ownnetwork_mode— the inbound payload is untrusted, so a routine fired by a webhook delivery can’t be used to reach an arbitrary host even if the authoring crew is nominallyfree. The crew’s ownallowed_domains(if configured) still apply on top of the shared defaults, same as a genuinely-restrictedcrew.
credential_ref.type is resolved at run time
against the workspace credential vault by type (case-insensitive match
on the vault type, e.g. API_KEY, GENERIC_SECRET), never by ID — so a
shared routine runs against any workspace holding a credential of the right
type. Only ACTIVE credentials resolve; credentials pinned to another crew
are invisible. Crew grants come from credential_crews, including a second
or later crew; the legacy crew_id field alone grants no access. When several match, the authoring crew’s own credential wins
over workspace-shared ones and the newest wins within each group (rotation).
The decrypted value goes into the outbound request only — never into logs,
the journal, or step output. An explicit credential_ref is required: no
matching credential, a missing resolver, an empty value or a resolver error
fails the step before sending the request. For a public endpoint that
requires no authentication, omit credential_ref. This also prevents a vault
failure between preflight and execution from sending an anonymous write.
Injection schemes: bearer
(default), header with explicit name, query with explicit name.
wait
approval_title is the one line the
inbox row shows. It is templated like approval_prompt, and that is the
point: "Approve: {{ inputs.action }}" renders a different row per run,
where the prompt’s first line is authored boilerplate and therefore
identical on every invocation. Without it, three pending approvals for a
credential rotation, a bucket deletion and a scale-out all read
"Approve this production action?" and cannot be told apart in the list.
Omitting it keeps the old behaviour — the row falls back to the prompt’s
first line — so routines written before the field exists are unaffected.
The rendered title is secret-redacted and truncated to 80 characters on
the same path the fallback is.
Declare the blast radius. risk_level is normal (the default when
unset) or destructive. destructive marks an approval whose action
cannot be undone by re-running it, and the inbox marks that row and its
decision card. It is author-declared and never inferred: a heuristic that
looks for “delete” in the prompt is wrong in both directions, and the
direction that matters — reading a destructive action as an ordinary one
— fails silently. An unrecognised value is rejected at save time rather
than being read as normal.
Three kinds: approval (HITL token), datetime (sleep until ISO timestamp), event (external signal via POST /api/v1/workspaces/{ws}/pipeline-runs/{runId}/signal). Both approval and event survive a process restart: a top-level run parks (status: WAITING) with durable state — pipeline_waitpoints for approval, pipeline_signal_waits for event (migration v154, #1409) — and at boot the parked run is resumed from that state. For event specifically, a signal delivered while the process was down (or a hair before the step even reached the wait) is not lost: the signal endpoint records the delivery durably before attempting any in-process wake, and the resumed step reads it straight back out (see Durability and restart recovery).
wait.timeout_sec sets the wait’s timeout. A positive timeout_seconds on
the step takes precedence when both are supplied. Omitting both (or using
zero) keeps the kind’s default: 24 hours for approval, one hour for event,
and no timeout for datetime. Existing parked waits retain their recorded
deadline when resumed.
Event deadlines. timeout_seconds also applies to parked event waits
(default: one hour). The deadline is fixed when the step first arms; retrying
or restarting the server does not reset it. A leader-gated worker checks parked
waits every second and resumes expired waits through the usual step failure,
retry policy / on_fail, and routine on_failure handling. A busy execution
slot can delay processing, but cannot extend the delivery deadline. Recovery
also checks deadlines that elapsed while the server was offline. A signal
committed before the deadline takes precedence even if consumed after it;
late signals cannot satisfy an expired wait. Cancelling a run closes its pending
subscriptions. Resumed pending event waits park again and release their slot.
For blocking nested waits, an in-memory wake is advisory: if a sibling receives
the durable signal, the pending own wait keeps its original timer and remains
cancellable until its own delivery or deadline. This does not change the shared
run/event identity used by nested waits.
When nested waits share a run and event type, another waiter can receive the
in-memory wake, so the intended waiter may not consume its durable payload until
its original deadline; the deadline is never extended.
retry_step applies its backoff but does not grant a fresh event window: once
expired, each retry reports the same timeout. Upgrading also applies the original
deadline to existing parked waits; waits already older than their configured
(or default one-hour) timeout can fail immediately and run failure hooks.
Legacy deadline recovery looks up top-level steps only. Existing nested event-wait
rows that cannot be matched use the one-hour fallback, even if their nested step
configured a longer timeout. Newly armed nested waits persist their configured
timeout.
Shutdown cancels event-resumed work already executing, matching other
lifecycle-owned runs; parked waits and expirations awaiting a slot remain
recoverable at the next boot.
Async, non-blocking. Hitting a kind: approval gate does not hold a goroutine or fail the run. A foreground crewship routine run returns promptly with status: WAITING (exit 0) and a waitpoint token, and releases its execution slot while it waits. Approving or rejecting resumes the run from the gate (already-completed steps are restored/skipped); a rejection resolves the run to FAILED cleanly rather than stranding it. Parked approvals whose timeout_sec elapsed are reconciled at the next boot scan.
Approve via UI Inbox, CLI crewship routine waitpoints approve <token>, or API.
Waking an event wait: by run, or by topic. There are two doors, and which one you use depends on whether the sender knows the run id.
wait: event usable as a subscription rather than a callback: several routines can park on mission.status_change and one delivery resumes all of them, each with the payload as its wait step’s output. The event_type is a free-form string — there is no registry to add to, so a producer and a consumer only have to agree on a name.
Both forms are workspace-fenced: a topic delivery never reaches a run in another workspace, and each parked wait is claimed exactly once, so two producers announcing the same event at the same instant cannot resume one run twice. Delivering a topic nobody is waiting on is a success with delivered: 0, not a 404 — an event source emits whether or not anything is listening. (The per-run form still answers 404 when that specific run is not at the wait step, because there the caller named a run and was wrong about it.)
code
A code step has a runtime. Two runtimes are wired today — expr and cel — both in-process, pure-Go, token-zero (no container, no LLM, no filesystem, no network). Use expr for a single boolean comparison (wake-gate probes); reach for cel as soon as you need real logic (booleans, arithmetic, string/list ops) — its own code comments call it out as the general-purpose deterministic primitive.
runtime: expr (wired, token-zero)
expr evaluates a single comparison and emits true or false:
- Operators:
>>=<<===!=. - The body is
Render()-ed first, so{{ inputs.x }}/{{ steps.y.output }}placeholders substitute before evaluation. - Anything that isn’t a single comparison (multiple operators, function calls, arbitrary code) fails closed with a clear error —
expris deliberately not a scripting language.
true only when work is needed). See Wake gates.
runtime: cel (wired, token-zero, general logic)
cel evaluates a Google CEL expression — non-Turing-complete (every expression provably terminates), so it keeps the token-zero / no-execution-surface guarantee of expr while adding boolean operators (&&, ||, !), arithmetic, string ops, list/map membership, ternaries, and field access. Reach for it when expr’s single comparison isn’t enough. A bool result emits true/false; numeric and string results emit their canonical string form. Compile/eval errors fail closed.
runtime: bash | python | go (rejected at author time)
runtime: expr or cel, or convert the step to agent_run).
script
The script step is the deterministic backbone of a routine: it runs a bundled script file — Python, bash, Node, any language your crew’s devcontainer installs — inside the crew’s own container, token-zero (no LLM). It’s the first-class replacement for the old workaround of an agent_run step whose prompt merely told an agent to shell out to a script: faster (no model turn), cheaper (no tokens), and reproducible (the same input always yields the same output).
A routine is not an LLM chain — it’s deterministic scripts (the fast, cheap, 100%-reliable backbone) + agent steps (judgment, where the output legitimately varies). The proven pattern: a script step does the mechanical heavy lifting (parse a bank statement, reconcile, verify a checksum), and a downstream agent_run applies judgment to its output.
{{ steps.parse.output }}:
1001:1001) via the same exec path an agent’s own shell tool uses — so it’s exactly as sandboxed as the crew already is: --cap-drop=ALL, no-new-privileges, read-only rootfs with a writable /tmp, and the container’s pid/mem/cpu caps. stdout becomes the step’s downstream output; a non-zero exit code fails the step; stderr is captured for the error/audit but never contaminates stdout. Every script step is recorded as a scrubbed exec.command journal entry (the exact argv, exit code, duration) for a durable post-hoc audit.
Path + interpreter. path resolves under the crew’s shared dir (/crew/shared/); traversal or an absolute escape is rejected. interpreter is optional — it’s inferred from the file extension when omitted:
An unknown extension with no explicit
interpreter is an author-time error. The interpreter, path, and args are assembled into an argv (no shell), so args can never inject.
Inputs flow two ways, exactly like code steps: every declared routine input is exposed as CREWSHIP_INPUT_<NAME_UPPER> in the script’s environment, and script.args / script.env values are template-substituted ({{ inputs.x }} / {{ steps.y.output }}) before the run. A default 300 s timeout applies (override with the step’s timeout_sec); stdout is capped at 1 MB.
Egress. A script step runs with HTTP_PROXY/HTTPS_PROXY pointing at the crew sidecar (127.0.0.1:9119), so outbound HTTP from a script obeys the crew network policy — a restricted crew’s script can only reach hosts on its allowed_domains. NO_PROXY keeps loopback direct.
Note this is the application-layer fence: a script that deliberately ignores the proxy (curl --noproxy '*', a raw socket) still reaches the network unless the crew is on the network-layer egress fence pilot (container.egress_fence_crews, see Container isolation). Without it, treat the allowlist as a guardrail for well-behaved tooling, not as containment for hostile code.
Deliver the script into the crew
Ascript step points at a file that must already live in the crew’s shared dir. Don’t hand-copy it or bake a base64 blob into the manifest — declare it on the crew and let crewship apply deliver it. Add a files: block to the crew manifest:
crewship apply materializes each file into the crew’s shared dir via the same validated /files/save path the crewship crew files CLI uses — idempotent, re-delivered when the source changes. (Ad-hoc, you can also crewship crew files save accounting shared/scripts/parse_vypis.py --file parse_vypis.py, but the manifest is the durable, declarative path.)
Overwriting a shared file needs the crew container running. A first-time delivery writes the shared tree host-side, but once a crew is provisioned its container (UID 1001) owns
/crew/shared, so an overwrite of changed content — re-delivery of a modified file, or routine import --force — is routed through the running container as that UID. If the crew container is stopped, that overwrite returns 409 with a “start the crew and retry” message rather than a silent failure. Re-delivering identical content is always a no-op (it succeeds even on a stopped crew), so a steady-state crewship apply never needs the container running.A script is a crew asset, not a routine field — it lives in the crew’s shared dir and is shared by every routine that crew authors. The crew manifest
files: block is the source of truth. See below for how a script still travels with routine export/import.The files: block works in both crew manifest shapes — a standalone kind: Crew document and a combined manifest that also nests agents:/skills:. Either delivers the file into /crew/shared at apply through the same validated path.Scaffold and portability
- Scaffold a script-backed routine with
crewship routine init --script— it emits ascriptstep feeding a downstreamagent_run, with delivery instructions in the description. - Portable bundles.
crewship routine exportinlines every referenced script file (base64) from the routine’s author crew, so a portable routine travels with its backbone — recipe + scripts + agent judgment.crewship routine import --crew <slug>re-materializes the inlined scripts into the target crew (same/files/savepath). A script whose destination already exists with different content fails loudly — pass--forceto overwrite (remember: a crew script is shared across routines, so overwriting may affect another routine).--no-scriptson either command skips inlining/materializing. Even with inlining, the crew manifest remains the source of truth; export/import is a portability convenience.
transform
Transform compatibility. Transform supports field/array paths and standalone
length, keys, tostring, @json/tojson; use code for arithmetic or pipes.
Field names are literal, including -, ? and !; this is not a full jq parser.
Whitespace around an array index is accepted. Save and draft validation check
all branches, including inactive ones. Existing definitions with unsupported
syntax need a manual correction before re-saving or draft testing; there is no
automatic migration. Ordinary execution of an existing routine still skips
inactive branches and reports an error if an unsupported expression is reached.
notify
wait: approval (which blocks for a decision). notify posts a rendered update and continues, so you can drop progress pings anywhere in the DAG (“extracted 3 invoices”) and a final result at the end. No LLM, no network.
Saying what the notice is. Without
category, every notify step routes as chat.replies — the category an inbox message maps to. That is fine for a progress ping and wrong for everything else: “the nightly deploy failed” and “here is your weekly digest” arrive under the same label, and the preference matrix people tune is invisible to you, the one author who knows what the event actually is.
routines.completed notification of its own when a run finishes, so a routine that says nothing still reports that it ended. If your routine unconditionally tells the whole workspace it finished under that same category, the generic one is suppressed — your message says what happened and carries the result, and getting both every run is how a channel earns a mute. Suppression is deliberately narrow: a notice behind an if, or aimed at a role, a user, a crew or the trigger, reaches fewer people than the generic one, so that one still goes out.
A category is a routing label, not a privilege: it selects which row of a recipient’s matrix the notice is matched against, and every row is still opt-in per user and still gated by the channel’s allowlist. An unknown category is rejected when the routine is saved — routing into one nothing matches would deliver to nobody while the run reported success. The full vocabulary is in crewship notify prefs --help.
Targeting a crew. to: "crew:<slug>" addresses a crew’s human audience — everyone listed as a member of that crew — and delivers one inbox card per member:
(run, step, user) — so a resumed or retried run never double-posts, and the anti-spam soft cap below applies per member independently. Every member is notified; there is no role filter or opt-in subscription yet.
If the crew doesn’t exist in this workspace, has no members, or can’t be looked up, the notice degrades to a workspace-wide notice rather than vanishing or failing the run: the step output is marked notified:degraded and the run carries a warning, so a typo’d slug is visible instead of silent.
The message lands live (WebSocket broadcast to members) and scrubbed (secrets in mid-run outputs are redacted before insert). Delivery is best-effort: a routine whose notification can’t be written never fails on that account — the run’s real work is already done. Retries are idempotent — each notify is keyed on (run, step) ((run, step, user) for a crew fan-out), so a re-run doesn’t double-post. Routine updates carry a routine_update subkind so the inbox can keep them in their own lane, separate from approvals and escalations.
Anti-spam soft cap. A single run may deliver at most 20 notices to any one recipient — enough for progress + result pings, low enough that a routine dropping a notify into every step of a wide DAG can’t bury someone’s inbox. It’s a soft cap: once a recipient hits it, further notices to them in that run are quietly dropped (logged), never a run failure. The cap is per (run, recipient), so notifying several people each gets its own budget, and a schedule firing repeatedly is unaffected — each run is a fresh budget.
For the zero-config version — a desktop ping when a run ends (success or failure) without authoring a step — run it with crewship routine run <slug> --wait and enable notifications (crewship notify enable).
To reach outside Crewship when a run completes or fails — email or a signed webhook to an admin, a Slack relay, or your own service — configure outbound notification channels. The notify step is the in-product half; outbound channels are the half that reaches people who aren’t watching the inbox.
query
source: "pipeline_runs" returns run counts, cost, and top failures over a trailing window (default 24h, capped at 720h/30 days regardless of what’s authored). This is the data source behind the workspace digest routine template.
Output is a JSON object — pipe it through a
transform step to pull a single field for a downstream notify/http step, exactly like any other structured step output:
{ window_hours, total_runs, completed, failed, waiting, cancelled, total_cost_usd, top_failures: [{pipeline_slug, count}], summary_md } — summary_md is a pre-rendered markdown digest, so the common case (query → notify) needs only the one transform step shown above to extract it.
Strictly workspace-scoped: the query always runs against the invoking run’s own workspace_id — a routine can never read another tenant’s run history no matter what it’s authored to ask for.
foreach — fan out over an array
{{ steps.process_each.output }} → ["…","…"], in input order).
Within an item the body runs like a mini-pipeline: per-step
retry:, validation, outcomes, and tier escalation all apply. Cost is summed across every item and attributed to the foreach step, so max_cost_usd still bounds the whole fan-out. The first item that fails cancels the rest and fails the step (fail-fast). wait, call_pipeline, and a nested foreach are not allowed inside the body — the fan-out is a bounded, self-contained unit. items may hold at most 10,000 elements, and because every item receives its own copy of the run inputs, a run with more than 1,024 inputs fails the step before any item runs.
crewship — act on Crewship’s own nouns
Every other step kind reaches outward: an http step to a third party, an
agent_run to a model. crewship reaches inward — it lets a routine write
back into the product it runs inside, so the loop “issue changes → routine
runs → routine comments on the issue” closes without an http step aimed at
your own API and a token you have to manage.
The step’s output is the API’s JSON response, so a later step can pick a field
out of it —
transform with .identifier gives you the new issue’s key.
Verbs
page.write is bounded twice over, and the first bound is not the autonomy
level. The routine may only write a panel that names it as the producer, or
one a human granted the crew or the acting agent produce on — an agent can
never issue that grant itself. On top of that, a strict crew’s routines do
not write panels at all: page_write holds for approval under strict, and on an
unattended run a hold is a refusal. That is deliberate. An unwritten panel goes
stale and says so, which is a better outcome than a number nobody approved.
Raise it with crewship policy set if a crew should push unattended.
relation_type is one of blocks, blocked_by, relates_to, duplicate_of,
sub_issue_of. A missing required arg is a save-time error, not a 400 at
03:00 — as is a verb with no policy action behind it, which is how a new
capability is kept from shipping ungoverned by being forgotten.
The loop the kind exists for, end to end — a probe, a comment on the issue that
triggered the run, and the issue moved on:
What bounds it
- Autonomy. The verb is gated on the author crew’s
autonomy_level. A crew whose level would hold the action for approval gets a refusal instead — a routine has nobody attached to approve it — so on astrictcrew, issue writes and assignments do not happen. Raise the level withcrewship policy set. The full per-level table, and the reasoning for each cell, is in Autonomy → what a routine may do to the board. - Escalation volume.
escalation.createis allowed at every autonomy level, because an approval to authorise an interrupt is itself the interrupt. What bounds it is a backlog instead: a routine may not escalate while the crew already hasescalation.max_pending_per_crew(default 10) unresolved escalations. Resolving them returns the budget. - Identity is not yours to set.
workspace_id,crew_id,agent_id,actor_agent_id,author_agent_idandauthor_run_idare injected from the run and override anything inargs. A routine cannot act as another workspace, a sibling crew, or a different agent — the last one matters because the acting agent is what the delegation depth cap is measured from. A routine with no author agent cannot useissue.comment, which needs an author it can name — so a routine containing that step is refused at save unless one is set. Set it withcrewship routine save --author-agent <slug|id>(the agent must belong to--author-crew), orauthor_agent_idon the save API. The refusal is deliberate: the same routine used to save clean and then fail every run at 03:00 with400: agent_id is required. The other five verbs have a documented no-agent fallback and are unaffected. - Chain depth. Whatever the verb creates records the run that created it, so an automation reacting to it inherits this run’s chain budget rather than starting a new one. See Chain depth.
dry_rundoes not write. A preview that files a real issue is not a preview; the step reports what it would have called.
Conditional if
Any step can carry an if: gate. Two forms, chosen automatically:
- CEL expression (no
{{ … }}): evaluated against the typed variablesinputs,steps, andrun. Real comparisons and logic work —"if": "inputs.dry_run == false","if": "steps.classify == \"spam\"","if": "inputs.count > 0 && run.is_replay == false". A boolean result gates directly; a number/string result falls through to the truthy rule below. - Template (
"if": "{{ inputs.run_summary }}"): rendered to a plain string, then truthy-checked — empty /false/0/no/off(case-insensitive) → step skipped and marked<skipped>in StepOutputs; anything else is truthy. A bare literal like"yes"/"false"follows the same truthy rule.
inputs.* (the run’s inputs, numbers stay numbers for arithmetic), steps.<id> (that step’s output string), and run.metadata.* / run.is_replay / run.replay_of. A skipped step never trips on_fail — “didn’t run” is structurally different from “ran and failed”.
DAG with needs[]
needs execute in parallel (one goroutine wave per ready set, bounded so a wide fan-out can’t stampede the provider into rate limits). Final output picks the unique leaf node; for multi-leaf DAGs the first leaf in source order wins.
parallelism — auto-derive the DAG
By default a routine is sequential unless you wire needs: (GitHub-Actions parity: no needs = depends on the previous step). Set a routine-level parallelism to change how independent steps are scheduled:
auto they run in one wave (~1× wall-clock) instead of one-after-another — no needs: required. auto is opt-in because it changes the implicit “depends on previous” semantics; a routine you didn’t mark keeps running exactly as before.
Lifecycle hooks (before_all / after_all / on_failure)
Routine-level hooks run deterministic side-channel steps around the main execution — a clean home for setup/teardown that isn’t part of the visible step graph. Hook steps must be code / http / transform (no agent_run — a hook must not recurse or spend tokens):
after_all and on_failure are best-effort — logged, but they never change the run’s outcome. Hooks fire only on the top-level run (not nested call_pipeline expansions) and are skipped on resume re-entry and dry-run. Per-step before / after hooks also exist and are included in the capability manifest walk. Full reference: Lifecycle hooks.
Per-step overrides (no version bump)
Tweak a single step’sprompt or model without bumping the routine version — the override is applied at run start over the versioned DSL, so the durable, authored definition stays the source of truth while an operator can patch and clear a live behavior quickly:
Debug a single step (step-run)
Iterating on one step’s prompt shouldn’t cost a whole pipeline run. crewship routine step-run executes a single step against an input fixture, in isolation — no upstream steps, no DAG, no persisted run record — and prints the step output, validation verdict, and (for agent_run) cost.
Supports agent_run, http, script, and transform. The deterministic three used to have no “run just this step” option at all — you had to run the whole pipeline to see whether a transform expression or an http step’s URL rendered correctly, even though they’re the cheapest, most deterministic steps in the DSL (no LLM, no waiting):
--inputis the step’s inputs fixture — a JSON object, inline or@file.json. The step’s{{ inputs.X }}refs resolve against it.--outputsseeds upstream{{ steps.X.output }}refs — a JSON object mappingstep_id→ that step’s output (a string, or any JSON value, which is stringified). Most non-first steps read an upstream output, so without--outputsthose refs render empty and you’d be iterating against different input than a real run — so the command warns loudly (⚠ step references {{ steps.parse.output }} but no --outputs fixture was provided…) when a referenced upstream output isn’t seeded, across whichever fields the step type actually templates (prompt foragent_run; url/body/headers forhttp; input/expression fortransform; args/env forscript). Still no DAG traversal — the fixture stands in for upstream steps; nothing upstream executes.--tier-override(trivial|fast|moderate|smart) swaps the step’s tier for cheap structural iteration on anagent_runstep; a step-levelmodel_overridestill wins. Ignored forhttp/script/transform— there’s no tier to override.- The response is marked
simulated: true— it does not create a run record, so it never shows up inroutine records/metrics or pollutes the run trace. Ascriptstep-run still executes for real in the crew’s container (real side effects — only the run-record bookkeeping is skipped), and anhttpstep-run still goes through the same egress/SSRF guard a real run enforces, not a relaxed check. - Not supported:
wait(blocks on external state),notify/call_pipeline(side effects that don’t make sense without a real run), andcode(needs the same sandboxed-container wiringscripthas, not yet extended here).
routine dry-run renders prompts but executes nothing, and a full run executes everything but is slow and costs real tokens. Backed by POST /api/v1/workspaces/{ws}/pipelines/{slug}/step_run.
Agentless routines
Declare"agentless": true to get a token-zero guarantee: the routine can never invoke an LLM, no matter who edits it later. This is what makes high-frequency automation (health checks, metric probes, TLS expiry watches) free to run on a tight cron.
http step fetches a JSON status, transform projects the number out of it, and the expr code step emits the boolean true/false — all token-zero. (A pure http + transform projection of an already-boolean field works too; reach for expr when you need the comparison.)
Enforced at two layers:
- Save time — validation rejects
agent_run(direct LLM spend),call_pipeline(the target resolves by slug at runtime, so a nested routine could gain an agent step later and silently break the guarantee),crewship(an @mention in an issue body wakes an agent, an assignment dispatches one — whether it happens depends on rendered content, not on anything provable at save time), andeval.onlinewithsample_rate > 0(online grading runs a grader agent against the routine’s runs). - Run time — the executor independently refuses to dispatch an LLM-capable step inside an agentless run, so even a definition written before the validator existed fails closed.
Two-tier execution
The economic value-prop: an Opus-class authoring model designs the routine, a Haiku-class executor model runs each invocation. Workspaceexecution_tiers_json maps complexity classes to (adapter, model). This is the map every workspace is seeded with (migration v78; nothing rewrites it later, so it is what a workspace runs until an operator edits the column):
"" or {}) or unreadable, or whose map
does not contain the requested tier, resolves that tier through the model
catalog’s Anthropic roles instead: trivial and fast
run the cheap role, moderate the provider default and smart the top
role — today claude-haiku-4-5, claude-sonnet-5 and claude-opus-5
(config/models.json). An unchanged seeded map contains all four named tiers
and uses its stored model IDs for them.
Per-step complexity annotation drives the resolver. With on_fail: "escalate_tier", a failed validation walks the fallback chain — practically: Haiku tries first, Sonnet on validation fail, Opus on second fail.
Tier override at runtime. The CLI flag
--model <model> is constructed from the resolved tier and passed to the agent’s CLI adapter, so a routine’s complexity: "fast" actually fires Haiku, not the agent’s default. CLIAdapter is preserved (so the agent’s CLAUDE_CODE / GEMINI_CLI / etc. wiring stays intact); only the model name swaps.Save validation gate
Save endpoints (sidecar/pipelines/save, user /api/v1/workspaces/{ws}/pipelines/save, internal /api/v1/internal/pipelines/save) require the routine to clear a validation gate before it persists. The gate is a dry-run validation of the draft, not a real execution — there is no “test run” mode (you cannot run an agent dry). The sidecar agent-authoring flow forwards the draft to /api/v1/internal/pipelines/test_run, which parses, schema-validates, and dry-runs it (rendering every template, invoking no agent); on success it mints an HMAC save_token bound to (workspace, definition hash, authoring crew) and forwards that token to save. InternalSave verifies the token — it does not trust a body-supplied “it passed” claim.
This is the self-improvement loop: an authoring agent that writes brittle DSL gets a structured failure report it can read and revise from. Without the gate, MVP would ship pipelines that pass schema but fail at runtime. Real execution happens on the first live run, and risky routines are human-reviewed (governance) before they go live.
Governance — agent proposes, human approves the risky ones
Routines have a lifecyclestatus (migration v128): active (live + runnable), proposed (awaiting approval), or disabled (admin airbag). The save validation gate still applies on top — status is an additional gate.
Maker-checker on save
When a routine is saved (by an agent via the sidecar, or by a user via the UI/CLI), Crewship classifies it. A save is risky if any of these hold:- it declares an
integrations_requiredthe author crew can’t currently satisfy (the same resolver the run gate uses); - it has any
http/egress step (or routine-levelegress_targets); - it has any
code-runtime step; - it declares
credentials_required.
agent_run / transform / call_pipeline / wait steps over satisfiable integrations, no egress, no credentials.
- Safe →
active. Goes live immediately, exactly as before. - Risky →
proposed. The routine is persisted but not runnable, and a blocking inbox item is raised forMANAGER+(the same Inbox surface as proposed skills). Approve it to go live, or reject it.
proposed (or disabled) routine refuses run / run_batch with 409 Conflict — "routine is awaiting approval" or "routine is disabled". dry_run always previews a saved routine, so it’s never blocked.
OWNER/ADMIN escape hatch —
skip_governance_gate. Symmetric with skip_test_gate: passing "skip_governance_gate": true on the user save (POST /api/v1/workspaces/{ws}/pipelines/save) forces a risky definition live as active and raises no review item. Honored only for OWNER/ADMIN (lower roles get 403); it is deliberately not available on the agent/sidecar save path (InternalSave), so agent-authored risky routines are always reviewed. This is what the crewship seed flow uses so a freshly-seeded workspace’s hand-curated starter routines are immediately runnable instead of stuck “awaiting approval”. Use it only for DSL you trust.Approve / reject (MANAGER+)
POST /api/v1/workspaces/{ws}/pipelines/{slug}/approve—MANAGER+. Flips toactive.POST /api/v1/workspaces/{ws}/pipelines/{slug}/reject—MANAGER+. Soft-deletes the proposed routine.
Disable / enable (OWNER/ADMIN airbag)
POST /api/v1/workspaces/{ws}/pipelines/{slug}/disable—OWNER/ADMIN. Flips todisabledand cancels any in-flight runs of that routine immediately.POST /api/v1/workspaces/{ws}/pipelines/{slug}/enable—OWNER/ADMIN. Returns it toactive.
crewship routine list) carry status; filter the queue with:
Draft trigger activation — a second, independent approval
The maker-checker gate above is about the routine’s definition: is this DSL safe to run at all. Atomic trigger authoring (below) adds a second, orthogonal question that applies even to a routine that isactive and not
risky: is this trigger safe to fire unattended, right now. Saving with
"activation": "draft" (--draft on the CLI) answers “not yet” — the
trigger is created disabled and one escalation inbox item asks a
MANAGER+ to turn it on. A routine’s status and its trigger’s activation
state can disagree: a safe, active routine can still have a trigger sitting
in draft, and a proposed routine’s trigger — created disabled either way,
since nothing runs before the routine itself is approved — waits on both.
Activate with crewship routine schedules activate <schedule_id> (see
Triggers below), which resolves that one inbox item.
Triggers
Atomic authoring — routine, version, and trigger together
crewship routine schedules create (below) is the general-purpose way to
attach, edit, or replace a trigger on an existing routine. crewship routine save --cron ... (and an agent’s save_routine tool call with a trigger
block) is the other path: it creates the routine, its first version, AND the
schedule in one transaction — either all three exist afterward, or none
do. This closes the gap where a routine authored by an agent could be saved
with no way to make it fire on its own; the trigger is no longer an optional
second step an authoring agent can forget.
--draft (or "activation": "draft" in the agent tool call) to create
the trigger disabled and raise one approval item instead of firing on that
schedule — see Draft trigger activation
above. A routine authored with no trigger and no explicit
--trigger-manual ({"kind": "manual"} for an agent) is flagged on the
routine page as a gap, not a silent success — say so on purpose if the
routine really is meant to run only on demand.
A bad --cron or unknown --timezone rolls the entire save back: the
routine and its version are not persisted either, exactly as if the whole
call had failed.
Cron schedules
Natural-language schedules (--when)
Pass --when instead of --cron for the common phrasings — it parses to a
cron expression and echoes the next 3 fire times so you can confirm the
schedule means what you intended before it saves:
every day at 9am, every weekday at 9, every weekend at 10am, every monday at 14:00 (any named weekday), every hour, every 15 minutes, every 2 hours. --cron and --when are mutually exclusive;
--when respects --timezone for the preview. Pass --yes to skip the
confirmation prompt (scripts / CI). Anything outside that phrase set returns
an error asking for a raw --cron expression instead of guessing wrong.
Missed-run catch-up
If a schedule falls overdue by more than one cron occurrence — the server was down, the schedule sat disabled for a while,next_run_at otherwise
fell behind — catchup_policy decides what happens to the backlog:
routine schedules list shows it in the MISSED column (4 (skip), 3 (once)) instead of
the gap being silently invisible, and a medium-priority MANAGER inbox
notice is raised the first time it happens for that occurrence.
catchup=all never “misses” anything (MISSED stays —) since every
occurrence in the backlog actually fires.
A wake-gated schedule (see below) always behaves like
catchup=once —
the probe is evaluated live (“is now the moment to wake”), never re-run
against a backdated historical timestamp, so skip/all only apply to
plain schedules. Missed occurrences are still counted and surfaced for a
gated schedule; only the multi-fire/no-fire policy expansion doesn’t apply.Wake gates
A plain cron fires the full routine — including its LLM steps — on every tick, even when there is nothing worth the model’s attention. A wake gate fixes that: the schedule references an agentless probe routine, the scheduler runs the probe first on each tick (free of LLM spend by the agentless guarantee), and the main routine fires only when the probe’s final output is truthy. Same falsy rule as stepif: conditions — empty, false, 0, null, nil, no, off (case-insensitive) skip the tick; anything else wakes the routine.
feed-watch-probe is agentless and
feed-change-report is the agent-written incident brief. They remain
unscheduled until you wire them together:
url, update the probe’s transform for that response
shape, and add the hostname to each routine’s egress_targets.
Semantics worth knowing:
-
The probe must be
agentless: true, live in the same workspace, and can’t be the schedule’s own routine — all validated when the schedule is saved. -
Probe errors fail open by default: a broken or deleted probe wakes the main routine instead of going silently blind, and records
last_wake_status: ERRORso you can see the probe needs fixing. This is the right default for a monitoring gate — you would rather over-fire than miss a signal. -
Fail closed for unattended gates: when the gate is meant to suppress an autonomous run — not just save tokens — a broken probe that fires the run anyway is a security hole (a tampered or unreachable probe cannot be allowed to green-light the run). Pass
--fail-closedso any non-affirmative probe outcome (error, timeout, or a non-COMPLETEDrun) holds the run instead of firing it. The tick recordslast_wake_status: HELD, does not incrementwake_fire_count, and advancesnext_run_atlike a skipped tick. The default stays fail-open, so existing schedules are unchanged.Note an empty or ambiguous output on aCOMPLETEDprobe run is already treated as falsy →SKIPPED(the main run never fires) regardless of policy —--fail-closedonly changes the failure branch. -
A skipped tick advances
next_run_atbut leaveslast_run_*untouched — run telemetry stays strictly about main runs. Wake telemetry lives inwake_check_count/wake_fire_count/last_wake_at/last_wake_status, androutine schedules listshows it as<probe> woke/checkedin the WAKE column. -
Probe executions are regular runs with
triggered_via: wake_check, so they’re auditable in run history and filterable out of dashboards.
Circuit breaker
A schedule whose target routine is broken (deleted agent, expired credential, a bug in the routine) fires on every tick, fails every time, and each failed run raises a MANAGER inbox alert — unbounded inbox spam and burned agent cost with no backstop. The circuit breaker auto-disables a schedule after 5 consecutive failed fires (the default; override per schedule with--max-failures), so a broken cron goes quiet instead of failing forever.
- The schedule is disabled (
enabled: false) withdisabled_reason: circuit_breaker— distinct from an operator-initiated disable (disabled_reasonstays empty), soroutine schedules listandroutine doctorcan tell you why a schedule went dark. - A journal event (
pipeline.schedule.circuit_breaker_tripped) and exactly one actionable MANAGER inbox alert fire for the trip itself — separate from the per-runfailed_runalerts, which still fire once per failed run. - A successful fire resets the consecutive-failure counter to 0, so an intermittently-flaky routine that eventually succeeds never trips.
routine schedules list surfaces the live counter in the FAILS column (2/5) and the tripped state inline in ENABLED (no (circuit_breaker)); routine doctor <slug> reports the same state as a schedule_circuit_breaker check (OK / WARN while approaching the threshold / FAIL once tripped).
Re-enabling clears the slate — the counter resets to 0 and disabled_reason clears:
The reliability editor
The routine detail page’s Plan view is an in-place editor for every field above, not just a read-only list (B9, #2362, fixing F18 — the create form used to offer name/cron/timezone/inputs only, even though the backend already accepted the rest). Opening a schedule’s edit dialog exposes:- When — cron expression + timezone, with a live preview of the next five fire times computed server-side as you type (see below).
- After downtime — the catch-up policy.
- Only run if — the wake gate, including fail-open/closed.
- On repeated failure — the circuit breaker threshold.
- Version — the pin, or track head.
- Health — last run, next run, consecutive failures, disabled reason, missed count and wake stats stay read-only on the list row; they are telemetry, not settings.
concurrency_key / max_concurrent) is shown read-only
here too, but for a different reason: those fields live on the routine’s
DSL, shared by every schedule/webhook/manual run of it, not on any one
schedule — so the dialog links to the routine’s recipe editor (Edit) instead of
offering a control that would silently change every trigger’s behaviour at
once.
Every field the dialog can set is backed by the same PATCH .../pipeline-schedules/{id} endpoint crewship routine schedules update
already used — nothing new server-side for this half.
A plan’s preset is checked when the plan is written
Creating a plan, editing one, and thetrigger block a routine save can
carry all validate the preset against the recipe it would run, with the same
rules the run itself applies: an unanswered required question, a choice
outside the declared options, a value of the wrong declared type or outside
declared bounds is a 400 (a 422 for the trigger block) naming the input,
and nothing is stored. Before this the preset was only checked when the
recipe later changed, so a plan could be born carrying values its routine
already rejects, sit enabled in the calendar looking healthy, and fail for the
first time at its next firing — with no run to inspect, because the executor
refuses before a run row exists.
A pinned plan is judged against the version it names, and that version has
to exist: pinning a plan — on create, on repin, or when retargeting it onto
another routine — to a version the routine never archived is a 400 naming
the version, and the plan is left as it was. A plan that already carries a
pin to nothing (written before this check) can still be disabled,
rescheduled or renamed, since an edit that mentions neither the pin, the
target nor the inputs is not judged; repairing it means moving the pin onto
a published version. If the archive cannot be read at all the edit is a
500, not a pass.
What is deliberately still allowed: false, 0 and an empty list are
answers, not absences; inputs the recipe does not declare are passed through;
and an input that declares no form (no widget, no options, no
allow_custom) is not validated here, because it is not validated at
execution either.
On an edit, what is judged is the plan the edit produces: the target
routine, the effective pin and the effective inputs together. Changing the
inputs, repinning to another version, unpinning onto HEAD, or retargeting the
plan all trigger the check against the recipe the plan will actually run;
disabling a plan, changing its cron or renaming it do not, so a plan that
predates this check can still be switched off or rescheduled without first
repairing a preset the edit never mentions.
Next-fire-times preview
GET .../pipeline-schedules/preview?cron_expr=&timezone=&count= (crewship routine schedules preview --cron ... --timezone ... --count 5) computes
upcoming fire times WITHOUT requiring a saved schedule, using real IANA
tzdata — so it reflects DST transitions exactly as the scheduler will, not
an approximation:
02:30 local time is skipped entirely
(the day the clock jumps 02:00→03:00 has none); a fall-back day’s ambiguous
local time can appear twice, exactly one hour apart in UTC. Both are what a
real cron daemon does against real tzdata, not a client-side guess — which
is the whole reason this is a server endpoint and not a JS cron library
running in the browser.
Missed occurrences on recovery
next_run_at is always computed from time.Now() at fire time, so downtime spanning several cron occurrences (server down for 3 hours on an hourly schedule) yields at most one fire on recovery — the routine still only runs once, it does not backfill the missed occurrences. Before #1409 that gap was invisible; now the scheduler emits one pipeline.schedule.missed_occurrences journal event per schedule when it detects the gap, reporting how many occurrences were skipped and the time window: “schedule X skipped 11 occurrence(s) between <window_start> and <window_end>”. This is observability only — pair it with the circuit breaker and journal search if you need to know a schedule went dark for a while.
Webhooks
/api/v1/webhooks/{token}. With HMAC, sender includes header X-Crewship-Signature: sha256=<hex_hmac_of_body>, validated server-side via hmac.Equal (timing-safe). Rate limited per token, per minute, default 60.
Replay defense — timestamped signatures (optional). A body-only HMAC is
replayable for as long as nothing else rejects it. Every accepted delivery
leaves a receipt keyed on its identifier (Idempotency-Key,
X-Crewship-Event-ID, the GitHub delivery id, or failing those a hash of the
token, signature and body) that answers a resend with the original receipt
for 30 days from acceptance, whatever the run then did — a failed run
does not reopen the window. The gap is a captured request resent under a
fresh identifier: to close it, a sender
can additionally include X-Crewship-Timestamp: <unix_seconds> and sign
"<timestamp>.<body>" instead of the bare body:
X-Crewship-Timestamp is present, the signature must cover the
timestamped material AND the timestamp must be within a 5-minute freshness
window — a captured signed request becomes useless once that window passes,
whatever identifier it is resent under. Omitting
the header falls back to the body-only scheme unchanged, so existing
senders keep working without any change.
Injection fence + forced egress for webhook-triggered runs. The request
body lands in the routine’s inputs as inputs.event (parsed JSON or raw
string), inputs.raw (the raw bytes as a string), and inputs.headers — all
attacker-influenced if the upstream sender’s payload carries untrusted text
(an issue title, a commit message, a customer-supplied field). Two
protections apply automatically, with no DSL changes required:
- Any
agent_runstep’s rendered prompt wraps those three input values in the same untrusted-ingress fence (<untrusted source="webhook" ...>) the agent-webhook path already uses — the model is told to treat fenced content as data, never instructions. Other template destinations (httpURL/body/headers,code,transform,if) are not fenced — wrapping those would corrupt a URL or a JSON body rather than protect anything; they’re covered by the egress hardening below instead. - Every
httpstep in a webhook-triggered run is held to the crew network policy’srestrictedfloor regardless of the crew’s actualnetwork_mode— see Egress enforcement above.
concurrency_key gate synchronously, reserves a run id, then answers immediately:
run_id, so senders with short delivery timeouts (GitHub ~10s, Stripe ~5s) never time out on long agent runs — and a sender closing the connection early cannot cancel an in-flight run: the run’s context derives from the server lifecycle, not the HTTP request. Poll the handle for the outcome:
routine result answers “what did run X produce?” — it re-fetches the run and prints the final output (structured JSON is pretty-printed; --format json for scripting), followed by a Files changed during this run section: the files on the run’s crew written inside the run window (report.pdf, summary.md, …) with a crew files get fetch hint. For a document-producing routine the files are the deliverable. Read the list as a correlation (“what changed on the crew while this ran”), not proof of authorship — a concurrent session or overlapping run on the same crew shows up too; see the CLI reference for the full caveat. routine logs now includes the output too; use result when the deliverable is all you want.
Client-facing views
The commands above are operator-shaped (run ids, tiers, cost columns,entry_type labels). Three affordances turn a run into something you can put in front of a non-engineer:
- Live progress —
crewship routine watch <slug> --progresscollapses the raw event tail into one updating status line:▶ reconcile-invoices · step 2/4 (verify) · RUNNING · $0.0021 · 12s. Step count comes from the routine definition; cost + elapsed from the run’s events. It exits when the run finishes. (Without--progress,watchstays the ANSI event tail for CI/scripting.) - Shareable report —
crewship routine report <run_id>assembles a run into a readable document: inputs → each step’s outcome + output → the final deliverable → cost & duration. Markdown by default (paste into a ticket/chat);-f html -o run.htmlwrites a self-contained page to hand to a customer (output is HTML-escaped — no injection from run data). - Redacted mode —
--clientonresult,report, andlogsdrops the internal noise (run-id, cost, tier, mode, trigger,entry_typelabels) and renders status in plain words (Succeeded/Failed), leaving just the routine name, inputs, and deliverable.
Idempotency-Key / X-Crewship-Event-ID, or identical bytes within the dedupe window) answers 202 with "status": "DEDUPED" and the original run’s id — the routine executes exactly once. A proposed/disabled routine answers 409 (policy block, nothing dispatched). A delivery arriving while the routine’s concurrency_key gate is at capacity answers 429 with a Retry-After header before anything is dispatched — a 429 never consumes the idempotency key, so redelivering the same event later executes it normally. Runs that hit a wait: approval step park as WAITING and resume once the waitpoint is approved, exactly as with other triggers.
Fire failure visibility. A fire that never becomes a run — governance/pin/concurrency rejected it, or the dispatch itself errored before a run existed — records a pipeline.webhook.fire_failed journal entry every time. A single failure is not surfaced beyond that: it is treated as noise-tolerant (a momentary DB busy, a governance load hiccup). Once the same webhook fails 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 fire resets the streak so a future run of failures can alert again. This does not cover a run that started and whose routine then genuinely failed — that already has full per-run visibility via the routine’s own failed-run journal entry and the Runs view.
Editing a webhook in place (F21)
BeforePATCH .../pipeline-webhooks/{id} existed, changing anything about a
webhook meant delete + recreate — which mints a new token and therefore a
new public URL, breaking every sender already configured against the old
one. crewship routine webhooks update (B9, #2362) fixes that: name, rate
limit, inputs template and enabled state all edit in place, and the
token/URL never rotates through this command, under any combination of
flags:
Manual
crewship routine run <slug> --inputs '{...}' or click the Run button in the UI detail panel. Same execution path.
After Run, the run detail shows recorded progress, decisions and results.
History retains prior runs. Test routine creates no real run: the public,
JWT-authenticated POST /api/v1/workspaces/{workspaceId}/pipelines/test_run checks
the draft and returns a workspace/user/recipe-bound save_token on success.
Publication verifies that proof. Sample step tests use the separate fixture_test
contract and explicitly supplied replacement data.
Deferred dispatch: delay, ttl, debounce, priority
A triggered run can be parked instead of firing immediately — useful for “run 60s from now” scheduling or for coalescing a burst of near-duplicate triggers into one run:ttl. An immediate run whose concurrency_key slot is full joins the same queue: it returns SCHEDULED with queued: true and starts when a slot frees, waiting at most its --ttl (one hour by default). Pass --reject-if-busy to get the old 429 instead. Full reference, including the underlying API fields: Deferred dispatch.
A deferred run is pinned to the version it was accepted against. The receipt carries pinned_version, and publishing an edit while the trigger waits does not change what fires — the run executes the recipe that passed its preflight, not whatever HEAD has become by the time the dispatcher reaches it. A debounced burst keeps the first trigger’s version, because coalescing makes it one trigger — and a later trigger in the window is judged against that version as well as the current one, so inputs written for a recipe published after the window opened are refused with 409 and the pending run is left as it was accepted; let it fire or cancel it first. pinned_version can only be named on an immediate or one-time (fire_at) start; a delayed or debounced start answers 400 to it. A routine without an archived version is refused with 409. Publish a version before deferring it; every accepted manual deferred start must have a durable recipe.
Every accepted start stays visible. A start that finds its
concurrency_key slot taken waits and retries until its ttl; without a ttl
it gives up after ten attempts. The routine’s Plan view lists waiting starts
under One-time starts with the reason (Waiting for capacity, how many tries,
the earliest next try — not a promised start time), and below them the recent
starts that left the queue: Started with a link to the run once it is
recorded, Did not run or Expired with the server’s reason, or Removed. A
started start without a run link may still be running; it is never shown as a
failure. The overview’s attention strip names accepted starts that did not run
in the last seven days, and Routines running now says how many wait for a
free slot. crewship routine pending list --status all and
crewship routine pending get <id> show the same receipts.
That is the opposite of a schedule, which deliberately runs current HEAD unless its target_pipeline_version says otherwise: a plan is a standing instruction, a deferred run is one start that a person already made.
Dry-run preview
Two execution modes, distinguished byMode in the request body and surface:
There is no
test_run mode: you cannot run an agent “dry” — it executes arbitrary scripts (bash, ansible, curl) whose side effects can’t be intercepted — so a real run is always run. The agent-authoring save gate validates a draft via a dry_run (structure + templates), not a real execution.
Dry-run is the safe “what would this routine do?” preview, and an honest static plan — not a proof the run will succeed. It walks the DSL, renders all template substitutions against the supplied inputs, resolves each step’s execution tier (adapter + model), and reports a would_execute list with per-step estimated cost. It also returns the routine’s declared manifest — the full blast radius (integrations, egress, credentials, agents, routines, datastores, tools, has_http, has_code) — so the UI can show “would use: ansible, Postgres, discord.com, agent jordan”. (A definition that no longer parses leaves manifest null and still returns the report.) No agents are invoked; no journal entries beyond a single pipeline.dry_run audit row are written.
/dry_run report remains available through the API and
CLI. Its technical report contains the step ID/type, resolved execution tier,
estimated cost and declared agent or child-routine target.
The estimate is intentionally labelled “estimated” everywhere it surfaces — it’s a planning aid, not a quote. Real cost only lands once you switch to run.
Versioning + rollback
Publishing a changed definition creates a new immutable row inpipeline_versions (v79 migration); saving a draft does not. The pipelines.head_version column points at the current. Rollback repoints HEAD at the target version and makes its definition live — no new row is created (versions are deduped by content hash), and routine versions marks the new HEAD:
Diffing versions
crewship routine diff <slug> --from N --to M prints a unified diff between two versions’ definitions (pretty-printed JSON, so a single field change shows as a single-hunk diff rather than the whole minified blob changing):
rollback calls the same endpoint automatically and prints a “What changed” view against the version it just rolled back from, so you see exactly what the rollback undid without a separate command. Identical versions (same definition_hash) print a one-line confirmation instead of an empty diff. See the CLI reference for the JSON shape and --format json output.
The improvement loop: routine iterate
Version history is also the substrate for self-improving routines. crewship routine iterate closes the loop that ad-hoc “agent loop” setups hand-roll in a single chat session — run, score, rewrite, repeat — but with the runtime’s guarantees attached:
--target, an optimizer agent proposes an improved definition → local validation → you confirm → the definition saves as a new immutable version. The change_summary on each version records the provenance (iterate round 2: score 74/100 — misses the TL;DR), so routine versions and the versions panel read as the improvement journal — and rollback undoes any round.
What makes this different from looping in a chat window:
- Budgets are enforced, not advised — every run and agent call lands in the Paymaster ledger under workspace budgets.
- The gates don’t move — each save passes the same server-side test-run gate and governance rules as a human author; a definition that gains risky steps (
http,code, egress) parks asproposedfor review and the loop stops. - Nothing is lost — full journal per run, one version row per accepted round, reversible by design.
Bundle export / import
Routines are portable across workspaces:--crew is required on import: it names the author crew that owns the routine and resolves its agent slugs. Scripts inlined in the bundle materialize into that crew’s shared dir (pass --force to overwrite a differing existing script, or --no-scripts to skip materialization).
The bundle format is crewship-pipeline-bundle/v1: routine row + (optionally) the full version chain + change_summary annotations. Author identity is rewritten on import so the importing user becomes the new author. Slug is preserved; if it conflicts in the destination workspace the existing row updates (new version), or you change the bundle’s slug before import.
HITL waitpoints
A routine that includes await step of kind: approval parks the run on a DB-backed waitpoint — without holding a goroutine or an execution slot. The triggering crewship routine run returns immediately with status: WAITING and the token (see the wait step). Operators decide via three paths:
/inbox remains
the place to act on approvals for runs
you didn’t just start; the routine’s History view opens each parked run.
The decision comment is forwarded to the parked run as the wait step’s output, so downstream steps can read approval rationale via {{ steps.<wait_step_id>.output }}.
Only a person decides. A waitpoint is answered by a workspace member with MANAGER+, or by whoever holds the token on the public callback. An agent cannot satisfy one: a peer agent saying “GO” — through its sidecar token, a comment, or any agent-facing route — is refused by the resolve door before the row is touched, the waitpoint stays pending for you, and the attempt is recorded in the audit log as waitpoint.decision_refused with the actor named. The inbox card states the audience as who_can_act: ["role:MANAGER"].
Durability and restart recovery
Run state is persisted to thepipeline_runs table at every step boundary: when a step starts, current_step_id is stamped; when it completes, the full step-outputs map and accumulated cost are flushed. A hard kill (crash, OOM, kill -9) therefore loses at most the step that was in flight.
At boot, the server scans for runs left in queued/running from the previous process lifetime and resumes them from the next unfinished step:
- Completed steps are restored, not re-executed. Their outputs feed downstream
{{ steps.X.output }}templates exactly as if the process had never died. - The in-flight step re-executes from scratch — at-least-once semantics. For an
agent_runstep this means the agent call is re-issued (and re-billed);http/code/scriptsteps with external side effects may fire twice. Design steps to be idempotent where that matters. - Runs parked on a
waitapproval step re-attach to the original waitpoint token. No duplicate approval card is created; the pending inbox item stays answerable across the restart, and approving it resumes the run. - Runs parked on a
wait: eventstep re-attach the same way (#1409): the wait durably ARMs apipeline_signal_waitsrow before parking, so a signal delivered viaPOST .../signalat any point — including while the process was down — lands in that row rather than an in-memory-only channel. Resume checks for an already-delivered payload first; pending top-level waits park again with the original deadline, and elapsed deadlines produce the normal timeout failure. A valid consumed payload can be replayed if a crash happened before the step output was saved. - DAG runs (
needs:) resume at wave granularity — the parallel scheduler flushes state when each wave completes, so a kill mid-wave re-executes that wave’s unfinished steps. call_pipelineboundaries are NOT persisted. A kill while a nested pipeline is executing re-runs the entire nested pipeline on resume — the parent’scall_pipelinestep is the unit of recovery, and the nested run’s own per-step progress is not checkpointed. Keep nested pipelines short or idempotent if a mid-flight kill matters to you.- The accumulated cost is restored too, so
max_cost_usdkeeps counting across the restart instead of resetting. Caveat: cost is flushed at step boundaries, so whatever the killed in-flight step had already spent before the kill is not in the restored total — the cap under-counts the true spend by up to one step’s worth (and the re-executed step is billed again on top). - A resumed run that finds its concurrency slot occupied waits and retries with capped exponential backoff (2s doubling up to 60s) instead of failing — losing the slot race to a freshly-fired scheduled run is a timing collision, not a reason to abandon hours of restored work. If the server shuts down while a run is still waiting for its slot, the row stays in-flight and the next boot resumes it again.
- A waitpoint that timed out while the process was down resumes, observes the expired token, and fails with
wait step "X" (approval) timed out— distinct from an operator clicking deny (… denied).
executed_definition_json) before dispatch, and resume re-enters that snapshot — so publishing an edit while a run is in flight, or rolling the routine back, does not interrupt it. Runs recorded before the snapshot existed fall back to the pinned version if the run had one, else to the current definition, and there the legacy drift gate still applies: a content-hash mismatch with what the run started on stamps the run interrupted (this catches in-place edits even when every step id survives, not just renamed/removed steps).
When persisted state is insufficient to resume safely, the run is stamped interrupted instead — never silently dropped, never wrongly resumed. Fallback triggers: the pipeline row is gone, the executed recipe is unreadable or no longer parses, the legacy hash drift above, unreadable persisted inputs/outputs, or a non-resumable mode (only live run rows resume; a dry_run preview row from a previous lifetime is never re-run). The reason lands in the run’s error_message.
Graceful shutdowns are different: an in-flight run cancelled by shutdown is finalized as cancelled (a terminal state) and is not resumed at next boot. Resume targets hard kills, where no terminal write could land.
cancelled covers every way a run’s context is CANCELLED, not just the Cancel button. The default synchronous POST .../run hands the executor the HTTP request’s own context, and Go cancels that context when the client connection goes away — so a closed tab, a proxy hanging up, or a CLI whose own per-call deadline fired and closed the socket all reach the server as context.Canceled and land a cancelled row reading run cancelled at step <id>. That matters beyond the label: cancelled runs mint no error fingerprint, page no failure notification and run no on_failure hook.
A DEADLINE is not a cancellation. A step that blew its own timeout_seconds, a run whose own context carried a deadline and exceeded it, and an approval nobody answered before its timeout_seconds all stay failed, keep their fingerprint and page as before: the work ran out of the time it was given, which is a result, not an interruption. The distinction is context.Canceled versus context.DeadlineExceeded, and the run row follows it.
If you need a run to outlive the request that started it, send Prefer: respond-async and poll the returned run_id; a run parked on a wait step already survives, because it releases the request before it parks.
Set CREWSHIP_PIPELINE_RESUME=off to disable resume and restore the older stamp-everything-interrupted behaviour — useful if a crash loop would otherwise re-burn the in-flight agent step’s tokens on every restart. Default is on.
Online eval sampler
The online sampler watches completed routine runs and grades a configurable percentage of them through the existing rubric grader so production traffic continuously feeds the drift detector — not just on-demand replays or scheduled regression suites. Per-routine DSL:- Scan
pipeline_runs WHERE status = 'completed' AND completed_at > watermark. - For each candidate, resolve the routine DSL. If
eval.onlineis absent orsample_rate <= 0, skip. - Draw from
crypto/rand. If the sample lands abovesample_rate, skip. - Otherwise, INSERT into
eval_runswithkind = 'online',status = 'queued'. The existing grader worker picks it up and writes the result back.
Correctness guarantees:
- Schema-layer idempotency —
eval_runshas a partialUNIQUE INDEX (pipeline_run_id) WHERE kind='online'. A duplicate sampler instance or a crash-recovery watermark replay can attempt the same enqueue twice; the second collapses to a no-op rather than queueing twice and double-billing the grader. (completed_at, id)tuple cursor — parallel fan-out steps that complete at the same nanosecond all get graded; a timestamp-only cursor would orphan siblings.- Stuck-on-error watermark — a transient per-row error (resolver outage, entropy outage, enqueue conflict) freezes the watermark at the row before the failure so the next tick retries. Deterministic skips (no eval config, sample roll missed) advance normally.
- Trace correlation — the eval row carries
routine_slug+pipeline_run_idso an operator clicking a low-scoring grade in the eval UI lands on the actual trace viapipeline_run_id-> trace.
- Watermark is in-memory only. On process restart it resets to
now - 1h; outages longer than that leave a gap in grading coverage. The UNIQUE index keeps reprocessing harmless. - Page cap is 500 rows/tick. A workspace processing > 500 routine runs/minute at
sample_rate = 1.0will accumulate backlog; the watermark only advances past successfully-handled rows, so the backlog drains across subsequent ticks rather than being lost.
Validation gates and credential leak guards
Each step’svalidation block runs after the step output materializes. The schema is a JSON Schema draft 2020-12 subset (most of type, required, properties, items, pattern, format, enum, etc.) plus three Crewship extensions:
must_not_contain: ["API_KEY=", "Bearer "]— output must include none of these substringsmust_contain: ["##"]— output must include all of thesemin_length/max_length— convenience for non-JSON outputs
must_not_contain gate is the credential-leak tripwire: if an agent is about to leak a real API key in its output, the gate fails the step before downstream consumers see it. Pair with on_fail: "abort" (set at the step level, alongside validation) for hard stop, or escalate_tier if you want the higher model to retry without the leak.
Observability
Every routine run emits a sequence of journal entries:pipeline.run.started— once at run beginpipeline.step.started— per steppipeline.step.container_ready— per agent step: how long the step spent acquiring its crew container (duration_ms). This isolates the container-provision cost from the LLM/tool time in the step’s total duration — so you can read the effect of container prewarming directly (a warm hit is near-zero, a cold provision is seconds) instead of guessing under LLM-latency noise. A transiently-retried step emits one record per attempt, distinguished by anattemptfield in the payload. Visible inroutine logs <run_id> --full(kept off the compact activity rail).pipeline.step.completed/pipeline.step.failed/pipeline.step.validation_failed— per step terminalpipeline.step.skipped— the step’sifcondition was false, so it never ran (payload carries thecondition). A dedicated entry type rather than acompletedrow with a marker, so a skipped branch is distinguishable from a real completion at the storage layer.pipeline.step.retrying— a transient failure the retry policy is about to swallow; non-terminal, one per attempt (attempt/max), followed by the step’s eventualcompleted/failed. (Rows written before this type existed arrive aspipeline.step.completed/failedcarrying akind=skipped/kind=retrypayload marker; readers honour that marker as a fallback.)pipeline.run.completed/pipeline.run.failed— once at run endsummary.generated— an LLM-generated outcome verdict (“did the run accomplish the goal?”), fired once after the run terminates. Shown as an outcome pill next to the status pill in the Runs sub-tab. Gated by therun_verdict_summariesfeature flag and skipped for agentless/dry-run routines — see the Crew Journal guide for the full contract.
Status, outcome and verdict
Three fields on a run answer three different questions, and the names are close enough to confuse (docs/prd/PRD-ISSUES-AND-ROUTINES-2026.md §9.6/§21 — “Outcome becomes a third
confusing status field” is a named risk of that document, and this section is how the PR
that added outcome discharges it):
status is mechanical and never lies about whether the process ran. runverdict is an
advisory opinion, generated after the fact by a model that did not do the work — useful
as a second opinion, never authoritative, and silently absent for agentless/dry-run
routines. outcome is the one deterministic routing decision: today it drives the
inbox (NEEDS_HUMAN → exactly one action-required card) and an issue session’s state
(NEEDS_HUMAN → awaiting_input); a run with an agent_run step that reports none of
the seven values still gets one — FAILED, with error_message set to "no outcome reported" — rather than being read as a silent success. A routine with no agent step
(an agentless: true probe, or one built only from script / transform / notify /
crewship steps) has nobody who could report a hand-off, so a clean run there is
SUCCEEDED with no error — the strict default flags a missing report, not a missing
agent. §9.6 also names an issue-comment lane for
WORK_CREATED/PARTIAL, which is not yet gated on outcome specifically — see the note
under API reference → Issues → Outcome for exactly what
is and isn’t wired.
The practical consequence for authors: every agent_run prompt should end by
asking for the line — for example “End your reply with exactly one line:
outcome: SUCCEEDED if you did the work, outcome: NO_CHANGE if there was
nothing to do, outcome: NEEDS_HUMAN if you are blocked.” A routine written
before this rule will show FAILED on every run until its prompt says so; the
first scheduled dogfood run on dev1 did exactly that.
The step’s chat is a durable transcript
Everyagent_run step gets its own chat (titled Pipeline <id> · step <id>, one per attempt), and that chat keeps both halves of the turn: the rendered prompt the step sent, and the agent’s answer with its reasoning and tool activity in the order they streamed. Read it with crewship chat <chat-id>, or open the chat in the dashboard — the transcript is the same one crewship chat stream shows live, so a browser left open on a running step still has the text after a reload. A step that failed after the agent said something keeps what it said, which is usually the most useful thing on the screen.
These chats raise no “agent replied” inbox item, by design: nobody asked the step a question, and a routine on a fifteen-minute cron would otherwise bell you ninety-six times a day per step. Use a notify step when a run genuinely needs to reach someone.
Live visibility — what is a routine doing right now?
While at least one routine run is in flight, the dashboard surfaces it from anywhere in the app:- Header chip — a pulsing “N routines running” pill appears in the toolbar next to the Online / Crews pills (hidden when nothing is active). If any run is parked on a human approval it turns amber and appends ”· M awaiting approval”. Clicking opens a popover with the six newest active runs — routine name, short run id, elapsed time, cost so far, current step — each with Open trace ↗ (deep-link to
/activity?run=<id>), Cancel (same manage-tier RBAC as the Runs tab), and a Review → shortcut into the routine for parked runs. With more than six active runs a “View all N running →” footer jumps to the Activity rail pre-filtered to the active bucket (/activity?status=active). /routineslist — a routine with an active run shows a live Running pill, and a parked run Waiting for you with its own banner above the list./routineslist table — the status cell swaps the historical “last run” pill for a live Running (or amber Awaiting approval) pill with current step · elapsed · cost, and live routines bubble to the top of the table regardless of the chosen column sort.
GET /api/v1/workspaces/{ws}/pipeline-runs?status=active + the pipeline.run.*/pipeline.step.started events, 3s poll while anything is active). status=active bundles running, queued, paused and waiting — the status a run parks in while a HITL waitpoint awaits a decision.
Run warnings
before_all/after_all/on_failure lifecycle hooks run best-effort: a failing after_all or on_failure hook (a teardown step like credential-release or cost-meter-close) never flips the run’s terminal status — a before_all failure is different and fails the run outright, since nothing downstream ran. A failed after_all/on_failure hook is instead recorded as a structured warning on the run so it doesn’t silently vanish into server logs while the run reports completed.
Fetch it via GET /api/v1/workspaces/{ws}/pipeline-runs/{runId} — the response’s warnings array (always present, empty when there are none) has one entry per failed hook:
crewship routine logs <run_id> (the slug-free state lookup) prints a Warnings: section when the run has any, alongside the existing Error: line for the run’s own terminal status.
Per-step cost + duration
Both the UI Runs sub-tab waterfall andcrewship routine logs <run_id> --slug X surface the cost_usd and duration_ms fields the executor stamps on every pipeline.step.completed event. Same data, two presentation surfaces:
- UI: right-aligned columns next to each step row, with a footer total summing the run. An em-dash (
—) renders for events that don’t carry cost (pipeline.step.started,.failed, live-only echoes) — easier to scan than$0.0000next to real values. - CLI logs: extra
DURATIONandCOSTcolumns in the timeline output. Same em-dash rule for non-positive values.
step.started+step.completed pair with its own cost on the retry, so the column-summed footer reflects the full spend (including retries), not just the first attempt.
Monthly budget meter
Per-step cost is the trace-level view; monthly budget (#1422 item 3) is the operator-level one — a spend cap you set for a routine, compared against actualpipeline_runs.cost_usd for the current calendar month:
max_cost_usd (above) — that’s a per-RUN hard gate authored into the definition and enforced mid-run by the executor (a run aborts if it would breach it). The monthly budget is external operator config, set out-of-band from the DSL (never touched by routine save, never bumps the version history), and is a view + soft cap indicator, not an enforcement gate — a routine over its monthly budget keeps running; nothing currently blocks the next invocation. A routine can have neither, either, or both knobs.
routine budget summary rolls up every routine that has a budget set OR spend this month (a routine with neither is excluded — noise reduction for workspaces with dozens of zero-cost deterministic routines):
GET/PATCH /api/v1/workspaces/{ws}/pipelines/{slug}/budget, GET /api/v1/workspaces/{ws}/pipelines/budget-summary — see the CLI reference for the full flag/response shape.
Run tags & metadata
Tag and annotate a run at invoke time to make it filterable later:crewship routine runs <slug> --tag prod); replays inherit the source run’s tags. Metadata is a free-form JSON object stored on the run and returned by GET /api/v1/workspaces/{ws}/pipeline-runs/{runId} — set at invoke time today (mid-run mutation and {{ run.metadata.X }} templating are a follow-up).
Replay & error fingerprinting
Failed runs are bucketed by a stable error fingerprint (failing step + normalized message), so recurring failures group together instead of scrolling past one-by-one in run history:is_replay=true + replay_of=<run_id>; gate a step on {{ env.is_replay }} to skip a side effect (e.g. a notification) on replay. Full reference: Run observability: tags, metadata, replay, errors.
Three terminal-side observability surfaces, ordered by when you’d reach for each:
For variance characterisation across many runs (is this routine production-ready at this tier?), use
crewship routine bench. For matrix-level cross-tier consistency (which scenarios diverge between Haiku and Opus?), use crewship eval scenarios and crewship eval baseline diff for CI regression gates.
RBAC
Common patterns
”Run this routine every day at 9 AM"
"Trigger this routine from a GitHub Actions hook"
"Validate routine DSL in CI before committing”
validate catches at author time what used to fail at save or on the first run: a concurrency_key that can render empty, unsatisfiable output gates (min_length > max_length, same token in must_contain and must_not_contain), and dead egress_targets entries (*, *.*, empty host — targets are literal/suffix matched, not globbed). Agent-slug typos fail here too if you supply the roster: --agents triage,writer (fully offline) or --author-crew growth (one GET /api/v1/agents call); the two flags are mutually exclusive. Details in the CLI reference.
”Discover what an agent is about to do, without running it”
would_execute report with which agent, which tier, the rendered prompt, and an estimated cost. Zero side effects.
”Watch a metric on a tight cron, spend tokens only on a spike”
Author an agentless probe that printstrue/false, then attach it as a wake gate:
routine schedules list shows how often the gate woke vs. checked.
“Get a daily ops digest of runs, cost, and failures”
The seededworkspace-digest routine (query → transform → notify, agentless, zero cost) covers this out of the box — wire a cadence with:
--crew names who’ll own it) and a daily 08:00 UTC schedule, idempotently. See the Digest CLI reference for --cron/--when and how delivery to Slack/email works (it’s the same notify step + notification-preference matrix every other routine uses).
”Cancel an in-flight run”
pipeline.run.failed with reason “cancelled”.
”Serialise runs per tenant / customer / repo”
Useconcurrency_key with a template referencing the tenant-identifying input:
account_id while the first is still running is rejected immediately with 429 and a Retry-After header — it does not queue or wait; requests for different account_ids run in parallel. If you want a burst of same-key triggers to collapse into one run instead of erroring, that’s a different mechanism — deferred dispatch’s debounce — not concurrency_key. Pair concurrency_key with the Idempotency-Key header for webhook handlers (see the Concurrency + idempotency recipe).
Also note: the concurrency gate is enforced per-process, not cluster-wide — a multi-replica deployment has no cross-replica coordination, so the effective cap is max_concurrent × replica count (see runs/active is single-replica scope for the same limitation on the adjacent endpoint).
The platform fails fast if the rendered key is empty (missing input + no literal prefix in the template) — see Troubleshooting for why and how to fix.
Troubleshooting
Save returns 422 "save requires a fresh, passing test_run"
Save returns 422 "save requires a fresh, passing test_run"
The save endpoint requires the test-gate cleared, and the server no longer trusts a body “it passed” claim — the old
last_test_run_at / last_test_run_passed fields are ignored (they were forgeable). The gate is cleared by an HMAC save_token: POST the draft to the public, JWT-authed POST /api/v1/workspaces/{ws}/pipelines/test_run first — it parses + Validates + checks the integration/resource preconditions + dry-runs the DSL (no agent invoked) and, on pass, mints a token bound to (workspace, definition_hash, user). Forward that token in the /save body as "save_token". crewship routine save performs this two-step (test_run → save) for you, so you normally never see this 422 from the CLI — it surfaces when a hand-rolled /save call omits the token.Alternative: crewship apply --skip-test-gate (CLI — the flag lives on apply, not routine save) / "skip_test_gate": true (API) if your role is OWNER/ADMIN and you trust the DSL — bypasses the gate explicitly.Run starts but no step events appear in the UI
Run starts but no step events appear in the UI
Check that
?include_steps=1 is in the runs URL the UI fetches. The list endpoint defaults to run-level only to keep payload bounded; the detail panel passes the flag explicitly. After a refresh, the waterfall populates from journal entries plus live WebSocket events.Schedule shows enabled=true but never fires
Schedule shows enabled=true but never fires
The scheduler ticks every 30s; with single-binary deployment, restarting the server resets the in-memory tick cursor. Pending schedules whose
next_run_at has passed will fire on the next tick after restart. If you’ve edited the cron expression, next_run_at recomputes from now() — so a 0 9 * * * schedule edited at 10:30 won’t fire until 9 AM tomorrow.Webhook returns 401 / 403 on a known-good token
Webhook returns 401 / 403 on a known-good token
HMAC mismatch: check the
X-Crewship-Signature: sha256=<hex> header, computed as HMAC-SHA256(signing_secret, request_body) over the raw bytes the sender sent (not a re-serialized form). The server uses hmac.Equal for comparison so timing-safe.Run cost is higher than estimated_cost_usd
Run cost is higher than estimated_cost_usd
Two-tier escalation walks the fallback chain. With
on_fail: escalate_tier, a failed Haiku run retries on Sonnet (5-10× more expensive), then Opus (20-50× more expensive) before giving up. Tighten validation gates (loosen must_contain, raise min_length), or set max_cost_usd on the routine to abort the run between steps when a cost ceiling is hit."Pipeline X step Y stuck in pending state"
"Pipeline X step Y stuck in pending state"
Since resume-from-step landed, a restart between the
wait step starting and a decision arriving re-attaches the run to its pending waitpoint at boot — approving via crewship routine waitpoints approve <token> resumes it. If the run shows interrupted instead, its persisted state was insufficient to resume (the reason is in the run’s error_message); the orphaned waitpoint can still be listed and rejected to clear the inbox. The boot log lines pipeline boot recovery done (resume-from-step) resumed=N interrupted=M and pipeline waitpoint store wired (...) stranded_pending=N show what recovery did.Run returns 500 "pipeline: concurrency_key rendered to empty value"
Run returns 500 "pipeline: concurrency_key rendered to empty value"
The DSL declared a non-empty Why fail-fast: a routine that declares
concurrency_key template but the rendered value is an empty string — typically because a referenced input was omitted at trigger time. Full error message:concurrency_key: "{{ inputs.account_id }}" is asking the platform to serialise runs per tenant. If account_id is missing, treating the empty key as “no gate” would silently allow unlimited parallelism for a routine the author explicitly asked us to serialise — a denial-of-self by misconfiguration. The executor refuses to start the run.Fixes, in order of preference:- Supply the input. The caller (curl /
crewship routine run --inputs '{...}'/ scheduler / webhook) needs to pass the referenced input. Webhooks expose the incoming payload underinputs.event;inputs_templateadds literal values and does not evaluate expressions or merge payload fields into top-level inputs. - Set a default on the InputSpec. If the input is genuinely optional but you still want a tenant-style gate, add
"default": "global"(or any non-empty sentinel) to theInputSpec. The platform merges defaults before rendering the key. - Use a literal prefix. A template like
"vendor-alert-{{ inputs.vendor_id }}"always renders non-empty (the literalvendor-alert-survives even whenvendor_idis missing); the key still gates, just less granularly. - Drop the gate. If you genuinely don’t want concurrency control, omit
concurrency_keyentirely (do NOT set it to""— that’s the unset sentinel).
crewship routine validate (and save) now reject a concurrency_key that can render fully empty — one built entirely from {{ inputs.X }} refs where none is required: true/defaulted. Applying fix #2 (anchor a referenced input) or fix #3 (a literal prefix) satisfies the check, so the empty-render failure is caught at author time instead of on the first bad run.CLI reference
For batch evaluation across the eval-* fleet (matrix sweep, head-to-head tier compare, baseline regression diff), see the eval CLI. Cookbook recipe 6 walks the eval-driven promotion workflow end-to-end.
The
pipeline alias is preserved for back-compat on the legacy subcommands (pipeline list, pipeline run, pipeline get, etc.). The post-rename additions — schedules, webhooks, waitpoints, validate, watch, logs, records, bench, doctor — are only registered under routine, so scripts that need them must switch.
Backend reference
- Migrations v78 (pipelines + execution_tiers_json), v79 (versions + waitpoints), v80 (schedules), v81 (run idempotency), v82 (webhooks), v115 (schedule wake gates), v153 (schedule circuit breaker), v154 (signal waits)
- Source:
internal/pipeline/(~10 700 LOC, 36 files) - API:
internal/api/pipelines.go,pipeline_runs.go,pipeline_schedules.go,pipeline_webhooks.go - Sidecar:
internal/sidecar/pipelines.go(port 9119) - Frontend:
app/(dashboard)/routines/,components/features/routines/, hooksuse-pipelines*
Production notes
Two caveats worth internalizing before you lean on routines for anything time- or cost-sensitive — both are permanent architectural properties of the current single-binary deployment, not bugs waiting on a fix: Instrumentation for the mention → wake → reply loop is real, not aspirational. The Issues & Routines PRD’s §19.3 service levels — comment-persisted-to-ack latency, first-agent-acknowledgement latency, lost/duplicate deliveries, context-pack size and compaction, checkpoint compliance, and outcome routing — are computed series onGET /metrics, not declared targets: see the Prometheus metrics reference for which series answers which of those questions (grouped as delivery, continuation, duplication, and human comprehension, per §24.1). A percentile with zero samples in its window is absent from the scrape, never reported as 0.
Limitations (current MVP)
These are known gaps in the current MVP — none block production use, but they shape what you can rely on today.- Resume is at-least-once, step-granular — restart recovery re-enters runs from the last persisted step boundary (see Durability and restart recovery). The step that was in flight at the kill re-executes from scratch; there is no sub-step checkpointing, nested
call_pipelineruns re-execute in full, and DAG runs recover at wave granularity. A run resumes on its captured recipe snapshot, so a publish during the run does not interrupt it; only runs recorded before the snapshot existed fall back tointerruptedon a content-hash mismatch.max_cost_usdunder-counts true spend after a crash — see Production notes. - Single-instance scheduler — running multiple replicas would double-fire schedules.
credentials_requiredscope field is documentary — thetypeis enforced at run time (a live run is refused with422+missing_credentialswhen the vault lacks an ACTIVE credential of that type — see Required credentials), but the optional per-entryscopeis not yet used for finer-grained matching.- No cross-adapter tier swap yet — same-provider model swap (Haiku→Opus) works; Claude→Gemini swap requires a shorthand→constant mapping not yet wired.
- Routine runs’ fleet-wide aggregates stay ad-hoc-only —
GET /api/v1/runsand the Runs tab’s recent-runs table now include routine runs (kind: "pipeline", keyed onactor_idsince a routine’spipeline.run.*entries carry notrace_id— see/api-reference/runs, #2284).GET /api/v1/runs/insights(the KPI/breakdown row above that table) and thestatstile embedded inGET /api/v1/runsitself were deliberately left ad-hoc-only — unifying cost roll-ups and correlation across the two engines is a separate decision. A routine’s own run history is unaffected either way:crewship routine runs-all/routine records <slug>/ the routine’s own Runs tab always had it. - No cross-run continuity — each run starts with an empty context; the previous run’s conversation is not carried forward, and
pipeline_routine_stateis a string key/value store for watermarks and counters, not memory. Give the agent what it needs in the routine’s brief or in state you write explicitly. - A trigger that has matched nothing is not flagged — an automation whose event type or payload filter can never fire is now refused at save time, but one that is valid and has simply never matched is not surfaced after any interval. Check
crewship automation listfor rules with no fires. - NL→cron covers a fixed phrase set —
routine schedules create --when(see Cron schedules) handles the common onboarding phrasings (every weekday at 9,every day at 9am,every monday at 14:00,every hour,every N minutes/hours) via a small hand-rolled parser, not a general NLP model; anything outside that set still needs a raw--cronexpression.
Check for assigned work before waking an agent
Use aquery step with source: "assigned_issues", followed by an agent step
with if: "{{ steps.check.output.has_work }}". The query returns
{"has_work": false} or {"has_work": true} without calling a model or starting
a container. The routine must have an author agent in its author crew. The
query uses that persisted identity, never an agent named in runtime inputs.
The signal considers TODO issues delegated to that agent in the same crew and
workspace. Human-mode work, unresolved blocking relations, live assignments
and live routine executions are excluded. Missing/deleted author identity or
a database error fails the step; it is not reported as an empty queue.
This is an advisory read, not an issue claim or execution permission. Work can
change after the check, including while a run waits or resumes. The agent must
use the normal issue start path, which applies current ownership and execution
gates. Do not put unrelated recurring work behind this condition: a daily
report can be useful even when no issues are assigned.
For a routine whose container steps are all conditional, Crewship defers
container startup until a condition actually passes. Unconditional runtime
steps retain the existing prewarm behavior. Use the existing routine schedule,
budget and run history; this pattern adds no independent heartbeat timer.
See the complete recipe.