Skip to main content
A workspace is the top-level tenant in Crewship. Every crew, agent, credential, skill, and pipeline belongs to exactly one workspace, and a user joins a workspace with a role (OWNER, ADMIN, MANAGER, MEMBER, VIEWER). Reach for these endpoints whenever you need to read or change a workspace itself or anything scoped under it — its members and their capabilities, invitations, pipelines and their runs/versions/schedules/webhooks, and the workspace skills registry. The standalone workspace-metadata calls (list/create/get/update) sit at the top; everything else is nested under /api/v1/workspaces/{workspaceId}/....
Workspace-scoped endpoints are nested under /api/v1/workspaces/{workspaceId}/... and require:
  • A valid JWT session cookie or a CLI token (crewship_cli_…) in the Authorization header.
  • The authenticated user to be a member of the workspace (enforced by the wsCtx middleware, which also injects workspace_id and role into the request context).
Errors follow RFC 7807 Problem Details — responses include type, title, status, detail, and instance.

Endpoints

MethodEndpointPurpose
GET/api/v1/workspacesEvery workspace the caller belongs to
POST/api/v1/workspacesProvision a workspace, caller becomes OWNER
GET/api/v1/workspaces/{workspaceId}Fetch a single workspace
PATCH/api/v1/workspaces/{workspaceId}Partial update of workspace fields
GET/api/v1/workspaces/{workspaceId}/membersAll members of the workspace
POST/api/v1/workspaces/{workspaceId}/membersAdd an existing user by user ID
DELETE/api/v1/workspaces/{workspaceId}/members/{memberId}Remove a member (not the OWNER)
GET/api/v1/workspaces/{workspaceId}/members/{memberId}/capabilitiesResolved capability set for one member
PATCH/api/v1/workspaces/{workspaceId}/members/{memberId}/capabilitiesMutate one member’s capability set
GET/api/v1/workspaces/{workspaceId}/members/capabilitiesCapabilities for every member in one call
GET/api/v1/workspaces/{workspaceId}/invitationsPending invitations
POST/api/v1/workspaces/{workspaceId}/invitationsIssue a token-gated invitation
GET/api/v1/workspaces/{workspaceId}/pipelinesWorkspace-visible pipelines
GET/api/v1/workspaces/{workspaceId}/pipelines/{slug}One pipeline with full definition
DELETE/api/v1/workspaces/{workspaceId}/pipelines/{slug}Soft-delete a pipeline
POST/api/v1/workspaces/{workspaceId}/pipelines/saveCreate or update a pipeline
POST/api/v1/workspaces/{workspaceId}/pipelines/{slug}/dry_runWouldExecute report + manifest, no agent runs
POST/api/v1/workspaces/{workspaceId}/pipelines/{slug}/runInvoke a saved pipeline
GET/api/v1/workspaces/{workspaceId}/pipelines/{slug}/runsJournal-backed run entries
GET/api/v1/workspaces/{workspaceId}/pipelines/{slug}/run-recordsColumn-typed run projection
GET/api/v1/workspaces/{workspaceId}/pipelines/runs/activeIn-flight runs (this replica)
GET/api/v1/workspaces/{workspaceId}/pipeline-runsCross-pipeline run feed
GET/api/v1/workspaces/{workspaceId}/pipeline-runs/{runId}Persisted state of one run
POST/api/v1/workspaces/{workspaceId}/pipelines/runs/{runId}/cancelCancel an in-flight run
GET/api/v1/workspaces/{workspaceId}/pipelines/{slug}/versionsPipeline version history
GET/api/v1/workspaces/{workspaceId}/pipelines/{slug}/versions/{n}One version with full DSL
POST/api/v1/workspaces/{workspaceId}/pipelines/{slug}/rollbackRoll head back to a version
GET/api/v1/workspaces/{workspaceId}/pipelines/{slug}/exportExport a portable bundle
POST/api/v1/workspaces/{workspaceId}/pipelines/importCreate a pipeline from a bundle
GET/api/v1/workspaces/{workspaceId}/pipelines/waitpointsPending approval waitpoints
POST/api/v1/workspaces/{workspaceId}/pipelines/waitpoints/{token}/approveApprove / reject a waitpoint
GET/api/v1/workspaces/{workspaceId}/pipeline-schedulesCron schedules
POST/api/v1/workspaces/{workspaceId}/pipeline-schedulesCreate a schedule
PATCH/api/v1/workspaces/{workspaceId}/pipeline-schedules/{scheduleId}Update a schedule
DELETE/api/v1/workspaces/{workspaceId}/pipeline-schedules/{scheduleId}Soft-delete a schedule
GET/api/v1/workspaces/{workspaceId}/pipeline-webhooksWebhooks
POST/api/v1/workspaces/{workspaceId}/pipeline-webhooksCreate a webhook
DELETE/api/v1/workspaces/{workspaceId}/pipeline-webhooks/{webhookId}Soft-delete a webhook
POST/api/v1/workspaces/{workspaceId}/skills/importImport one SKILL.md
POST/api/v1/workspaces/{workspaceId}/skills/bulk-importBulk-import skills from a git repo
POST/api/v1/workspaces/{workspaceId}/skills/generateGenerate a skill via LLM
DELETE/api/v1/workspaces/{workspaceId}/skills/{skillId}Delete a skill

Workspace metadata

Read and manage the workspace record itself — list the ones you belong to, create new ones, and update name/slug/language.

List workspaces

GET /api/v1/workspaces
Returns every workspace the authenticated user belongs to, ordered by created_at DESC. Soft-deleted workspaces (deleted_at IS NOT NULL) are excluded. Auth: Any authenticated user. Response: 200 OK
[
  {
    "id": "ws_cm1abc123",
    "name": "Acme Robotics",
    "slug": "acme-robotics",
    "logo_url": null,
    "preferred_language": "English",
    "created_at": "2026-04-12T09:18:22Z",
    "updated_at": "2026-05-09T14:02:11Z",
    "currentUserRole": "OWNER",
    "_count_crews": 4,
    "_count_agents": 12,
    "_count_members": 7
  }
]
FieldTypeDescription
idstringWorkspace ID (CUID)
namestringDisplay name (2-100 chars)
slugstringURL-safe identifier (2-50 chars, globally unique)
logo_urlstring?URL to workspace logo
preferred_languagestring?Canonical language name (e.g. "Czech", "English") — see language list
currentUserRolestringCaller’s role in this workspace (OWNER / ADMIN / MANAGER / MEMBER / VIEWER)
_count_crewsintegerNumber of non-deleted crews (omitted from the JSON when 0)
_count_agentsintegerNumber of non-deleted agents (omitted when 0)
_count_membersintegerNumber of workspace members (omitted when 0)
created_at / updated_atstringRFC 3339 timestamp

Create workspace

POST /api/v1/workspaces
Provisions a new workspace and adds the calling user as OWNER in a single transaction. Auth: Any authenticated user. Request body:
FieldTypeRequiredDescription
namestringyesDisplay name (2-100 chars)
slugstringyesURL-safe identifier (2-50 chars, must be globally unique)
preferred_languagestringnoEither a canonical name ("Czech") or ISO code ("cs", "pt-BR"); validated against validLanguages. Empty string is allowed and stored as NULL.
{
  "name": "Acme Robotics",
  "slug": "acme-robotics",
  "preferred_language": "en"
}
Response: 201 Created — same shape as the List response item (without currentUserRole / counts).
StatusCondition
400Missing/invalid name, slug, or preferred_language
401Not authenticated
409slug already taken

Get workspace

GET /api/v1/workspaces/{workspaceId}
Response: 200 OK — single workspace object (same fields as List) with currentUserRole populated from the JWT.
StatusCondition
404Workspace not found or soft-deleted

Update workspace

PATCH /api/v1/workspaces/{workspaceId}
Partial update — only provided fields are changed. Setting preferred_language to an empty string clears it (NULL). Auth: OWNER or ADMIN (canRole "manage"). Request body: All fields optional.
FieldTypeDescription
namestring2-100 chars
slugstring2-50 chars; must remain unique across all workspaces
preferred_languagestringCanonical name or ISO code; empty string clears the field
Response: 200 OK — updated workspace object.
StatusCondition
400Invalid field values
403Caller is not OWNER/ADMIN
409New slug already taken
There is no DELETE /workspaces/{workspaceId} endpoint and no crewship workspace delete command. Workspace deletion is handled out-of-band (a direct operation on the host database); deleting the last workspace would orphan its owner, so it is intentionally not exposed through the API or CLI.

Members

List the people in a workspace, add an existing user directly, or remove one. (To bring in someone who has no account yet, issue an invitation.)

List members

GET /api/v1/workspaces/{workspaceId}/members
Returns all members of the workspace ordered by created_at ASC, joined to users for display fields. Response: 200 OK
[
  {
    "id": "wm_cm9xyz",
    "workspace_id": "ws_cm1abc123",
    "user_id": "user_jdoe42",
    "role": "OWNER",
    "created_at": "2026-04-12T09:18:22Z",
    "updated_at": "2026-04-12T09:18:22Z",
    "user": {
      "id": "user_jdoe42",
      "email": "jdoe@acme.example",
      "full_name": "Jane Doe",
      "avatar_url": "https://acme.example/avatars/jdoe.png"
    }
  }
]

Add member

POST /api/v1/workspaces/{workspaceId}/members
Adds an existing user to the workspace by user ID.
Use this when the user already has a Crewship account; for unknown email addresses, use Create invitation instead.
Auth: OWNER or ADMIN (canRole "manage"). Request body:
FieldTypeRequiredDefaultDescription
user_idstringyesTarget user’s CUID
rolestringnoMEMBEROne of ADMIN, MANAGER, MEMBER, VIEWER (OWNER cannot be assigned via API). Assigning ADMIN requires the caller to be OWNER.
{
  "user_id": "user_jdoe42",
  "role": "MANAGER"
}
Response: 201 Created — the new member row.
StatusCondition
400Missing user_id, invalid role
402License member limit reached
403Caller not OWNER/ADMIN, or tried to assign ADMIN without being OWNER
404user_id does not match any user
409User is already a member
When the workspace’s license caps the member count, adding a member that would exceed it returns 402 Payment Required with the limit detail in the Problem Details body. The check runs before the request body is read, so a capped workspace rejects every add regardless of payload. The same limit applies to Create invitation.

Remove member

DELETE /api/v1/workspaces/{workspaceId}/members/{memberId}
Removes a workspace member. You cannot remove the workspace OWNER.
Auth: OWNER or ADMIN (canRole "manage"). Response: 200 OK
{ "success": true }
StatusCondition
403Caller not OWNER/ADMIN, or target member is the OWNER
404memberId not found in this workspace

Member capabilities

Capabilities are per-member string grants layered on top of a member’s RBAC role. They let a workspace admin hand an individual user a specific higher-tier action — “let this MEMBER create routines” — without promoting them to MANAGER. The role still sets the baseline; capabilities only ever widen it. The closed set of seven capabilities:
CapabilityGates
chatBaseline — talk to agents. Always implied; every member has it even when the stored set omits it, and it cannot be revoked (remove the member instead).
routine.createCreate pipeline schedules (cron-driven routines)
skill.createGenerate / import skills
credential.createCreate credential rows (fresh secret material in the vault)
credential.rotateRotate an existing credential’s value
issue.createFile issues
memory.writeWrite to agent / crew / workspace memory via /remember
Presets are named bundles for the common combinations:
PresetCapabilities
chatchat
powerchat, routine.create, issue.create, memory.write
adminall seven
A member with no explicit set falls back to a role-derived default (OWNER/ADMINadmin bundle, MANAGER → power-equivalent, others → chat). The capability lists returned by these endpoints are always sorted alphabetically. All three endpoints require the caller to be ADMIN or OWNER.
Path-param note: on the per-member capability endpoints the {memberId} segment is the member’s user ID (it is matched against workspace_members.user_id), not the workspace_members.id row ID used by Remove member. The user_id field in the response echoes the value you passed in.

Get member capabilities

GET /api/v1/workspaces/{workspaceId}/members/{memberId}/capabilities
Returns the resolved capability set and role for a single member. Listing a member’s capabilities reveals the workspace’s permission topology, so the endpoint is admin-gated. Auth: ADMIN or OWNER. Response: 200 OK
{
  "user_id": "user_jdoe42",
  "role": "MEMBER",
  "capabilities": ["chat", "issue.create", "routine.create"]
}
StatusCondition
403Caller below ADMIN
404memberId not found in this workspace

Update member capabilities

PATCH /api/v1/workspaces/{workspaceId}/members/{memberId}/capabilities
Mutates a member’s capability set. The body must contain exactly one of four mutation shapes:
FieldTypeEffect
setstring[]Replace the entire set with these capabilities
grantstring[]Add these to the current set
revokestring[]Remove these from the current set
presetstringApply a named bundle — "chat", "power", or "admin"
Empty arrays are rejected (a 400, not treated as a no-op) — to reset a member to chat-only, send {"set": ["chat"]} explicitly. chat is always implied: it is silently kept on set, and revoking it is rejected.
{ "preset": "power" }
{ "grant": ["routine.create", "issue.create"] }
Auth: ADMIN or OWNER. Guards:
  • A caller cannot mutate their own capability row (defence against a downgrade-then-restore stunt) — 403.
  • OWNER capability rows are immutable — any attempt returns 403.
  • The request body is capped at 16 KB — a larger body returns 413.
Response: 200 OK — the post-mutation state (same shape as Get):
{
  "user_id": "user_jdoe42",
  "role": "MEMBER",
  "capabilities": ["chat", "issue.create", "memory.write", "routine.create"]
}
StatusCondition
400Zero or more than one mutation shape, empty array, unknown capability / preset, attempt to revoke chat, invalid JSON
401Not authenticated
403Caller below ADMIN, caller mutating own row, or target is an OWNER
404memberId not found (including a row deleted concurrently mid-update)
413Request body exceeds 16 KB

List all member capabilities (bulk)

GET /api/v1/workspaces/{workspaceId}/members/capabilities
Returns the resolved capabilities for every member of the workspace in one round-trip — this drives the Members capability grid without an N+1 fan-out across the per-member endpoint. Rows are ordered by membership created_at ASC so the grid renders stably between page loads. Auth: ADMIN or OWNER. Response: 200 OK
{
  "members": [
    {
      "user_id": "user_jdoe42",
      "role": "OWNER",
      "capabilities": ["chat", "credential.create", "credential.rotate", "issue.create", "memory.write", "routine.create", "skill.create"]
    },
    {
      "user_id": "user_asmith7",
      "role": "MEMBER",
      "capabilities": ["chat", "issue.create"]
    }
  ]
}
StatusCondition
403Caller below ADMIN

Invitations

Invitations issue a token-gated link that the recipient redeems via the auth flow. They expire after 7 days.

List invitations

GET /api/v1/workspaces/{workspaceId}/invitations
Returns pending (un-accepted) invitations ordered by created_at DESC, joined to users for the inviter. Response: 200 OK
[
  {
    "id": "inv_cm0aaa1",
    "workspace_id": "ws_cm1abc123",
    "email": "newhire@acme.example",
    "role": "MEMBER",
    "invited_by": "user_jdoe42",
    "token": "EXAMPLE-NOT-A-REAL-32-BYTE-HEX-TOKEN",
    "expires_at": "2026-05-26T09:18:22Z",
    "accepted_at": null,
    "created_at": "2026-05-19T09:18:22Z",
    "inviter": {
      "id": "user_jdoe42",
      "email": "jdoe@acme.example",
      "full_name": "Jane Doe"
    }
  }
]

Create invitation

POST /api/v1/workspaces/{workspaceId}/invitations
Auth: OWNER or ADMIN (canRole "manage"). Assigning the ADMIN role requires the caller to be OWNER. Request body:
FieldTypeRequiredDefaultDescription
emailstringyesRecipient email
rolestringnoMEMBEROne of ADMIN, MANAGER, MEMBER, VIEWER
Response: 201 Created — invitation object with the freshly minted hex token (this is the only time the token is returned).
StatusCondition
400Missing/invalid email or role
402License member limit exceeded
403Caller not OWNER/ADMIN, or tried to invite as ADMIN without being OWNER
409Email belongs to an existing member, or there’s already a non-expired invitation for this email

Pipelines

Pipelines (also called routines in the UI) are versioned, workspace-scoped DSL programs that orchestrate agent runs, sub-pipeline calls, parallel forks, and approval waitpoints. See the Pipelines guide and the DSL reference for authoring details.

List pipelines

GET /api/v1/workspaces/{workspaceId}/pipelines
Returns workspace-visible, non-ephemeral pipelines. Each row is enriched with author_agent_name (best-effort lookup) and the most recent 3 issue identifiers bound via missions.routine_id so the UI can render an “ENG-5, ENG-9 +1” chip without a second fetch. Query parameters:
ParameterTypeDefaultDescription
include_ephemeral1offInclude auto-generated delegation-wrap pipelines
include_hidden1offInclude rows with workspace_visible=0
author_crew_idstringFilter to one author crew
orderpopularity / recent / namepopularitySort order
Response: 200 OK
[
  {
    "id": "pipe_cmRoutine42",
    "slug": "weekly-changelog",
    "name": "Weekly changelog",
    "description": "Summarise merged PRs into the public changelog",
    "dsl_version": "v1",
    "definition_hash": "f3c1...",
    "ephemeral": false,
    "workspace_visible": true,
    "invocation_count": 17,
    "last_invoked_at": "2026-05-18T08:00:00Z",
    "last_invocation_status": "COMPLETED",
    "author_crew_id": "crew_eng",
    "author_agent_id": "agent_eva",
    "author_agent_name": "Eva",
    "author_user_id": "",
    "authored_via": "agent",
    "linked_issue_count": 3,
    "linked_issues": ["ENG-5", "ENG-9", "ENG-12"],
    "created_at": "2026-04-30T11:00:00Z",
    "updated_at": "2026-05-18T08:00:01Z"
  }
]
The definition field is omitted from the List response and only returned by Get pipeline.

Get pipeline

GET /api/v1/workspaces/{workspaceId}/pipelines/{slug}
Returns the same shape as List but with the full definition (raw DSL JSON) inlined.
StatusCondition
404Pipeline not found in this workspace

Delete pipeline

DELETE /api/v1/workspaces/{workspaceId}/pipelines/{slug}
Soft-deletes the pipeline (deleted_at set). Versions, run history, and bound schedules/webhooks are retained.
Auth: OWNER or ADMIN (canRole "delete"). Response: 204 No Content
StatusCondition
403Caller below ADMIN
404Pipeline not found

Save pipeline

POST /api/v1/workspaces/{workspaceId}/pipelines/save
Creates or updates a pipeline for the calling user. authored_via is always set to "user_api" and author_user_id is extracted from the JWT (callers cannot forge identity). Auth: MANAGER+ (canRole "create"). Request body:
FieldTypeRequiredDescription
slugstringyesURL-safe pipeline slug
namestringnoDisplay name (falls back to slug)
descriptionstringno
definitionobjectyesDSL JSON document
author_crew_idstringnoPin a crew context for runtime resolution; without it, runs fall back to the first crew the saving user belongs to
last_test_run_atstringconditionalRFC 3339 timestamp (within the last 5 minutes) clearing the validation gate; required unless skip_test_gate is used
last_test_run_passedbooleanconditionalMust be true if relying on body-trust
skip_test_gatebooleannoOWNER/ADMIN-only escape hatch — bypass the 5-minute validation gate entirely
Response: 201 Created — full pipeline object including definition.
StatusCondition
400Missing slug or definition, invalid JSON
401Not authenticated
403Role below MANAGER, or skip_test_gate requested without OWNER/ADMIN
409Slug already exists in this workspace
422DSL parse / validate / cycle-detect failure, or validation gate not satisfied
There is no public test_run endpoint. You cannot run an agent “dry” (its scripts have uninterceptable side effects), so a real run is just Run. Drafts are validated server-side on /save (parse + schema + cycle detection), and the sidecar agent-authoring flow validates a draft via the internal dry-run gate (/api/v1/internal/pipelines/test_run, X-Internal-Token) before persisting.

Dry-run a saved pipeline

POST /api/v1/workspaces/{workspaceId}/pipelines/{slug}/dry_run
Returns the structured WouldExecute report for the supplied inputs plus the routine’s declared manifest — no agent invocations, no journal entries. The dry-run is an honest static plan, not a proof the run will succeed. Request body: Same shape as Run. All fields optional. Response: 200 OK — a RunResult (with would_execute) plus a sibling manifest object:
{
  "status": "DRY_RUN_OK",
  "would_execute": [ /* per-step plan */ ],
  "manifest": {
    "integrations": ["github", "slack"],
    "egress": ["api.example.com", "discord.com"],
    "credentials": [{ "type": "stripe" }],
    "agents": ["jordan"],
    "routines": [],
    "datastores": [{ "type": "postgres", "name": "main" }],
    "tools": [{ "type": "ansible", "name": "deploy.yml" }],
    "has_http": true,
    "has_code": false
  }
}
manifest is the routine’s full declared blast radius. It is best-effort: a stored definition that no longer parses leaves manifest null and still returns the report.

Run pipeline

POST /api/v1/workspaces/{workspaceId}/pipelines/{slug}/run
Invokes a saved pipeline. Returns synchronously with the full RunResult; for live progress, subscribe to the workspace WebSocket channel and filter pipeline.* journal entries by run_id. Request body:
FieldTypeDefaultDescription
inputsobject{}Input map — defaults from the pipeline’s input spec are applied for missing keys
tier_overridestringOne of trivial / fast / moderate / smart — replaces every agent_run step’s complexity for this run only. Unknown values are silently ignored.
triggered_viastringmanualClosed enum: manual, schedule, webhook, call_pipeline, issue
triggered_by_idstringFree-form attribution id (e.g. an issue identifier when triggered_via=issue)
Headers:
HeaderDescription
Idempotency-KeyDedupes redeliveries within 24h — a second request with the same key returns the original run with status="DEDUPED". Falls through silently when the idempotency store isn’t wired.
X-Crewship-Invoking-Crew / X-Crewship-Invoking-AgentInjected by the sidecar when an in-container agent triggers the run
Response: 200 OKRunResult.
StatusCondition
404Pipeline not found
429A run with the same concurrency_key is already in flight (sets Retry-After: 5)
503Runner not wired

RunResult shape

{
  "run_id": "run_cm9abc",
  "pipeline_id": "pipe_cmRoutine42",
  "status": "COMPLETED",
  "mode": "run",
  "output": "Drafted changelog for sprint 42",
  "step_outputs": {
    "draft": "...",
    "review": "..."
  },
  "cost_usd": 0.0241,
  "duration_ms": 18432,
  "deduped": false
}

List pipeline runs (journal-backed)

GET /api/v1/workspaces/{workspaceId}/pipelines/{slug}/runs
Returns pipeline.run.* journal entries for the named pipeline, newest first. Query parameters:
ParameterTypeDefaultDescription
limitinteger50Hard cap 500
include_steps1offWiden to pipeline.* (also include pipeline.step.* entries for the waterfall view)
Each row carries id, ts, entry_type, severity, summary, pipeline_id, run_id, and the raw payload.

List pipeline run records (projection table)

GET /api/v1/workspaces/{workspaceId}/pipelines/{slug}/run-records
Column-typed scan over pipeline_runs (migration v83). Faster than /runs because it skips the journal LIKE-pattern + json_extract path. Returns 503 with a legacy: "/runs" hint when the run store isn’t wired. Query parameters:
ParameterTypeDefaultDescription
limitinteger50Hard cap 500
statusstringFilter to a single RunStatus (queued, running, completed, failed, cancelled; plus dry_run / interrupted). There is no paused run status here — a run waiting on an approval keeps its running status in this table.
Response: 200 OK — array of run records. Each record:
{
  "id": "run_cm9abc",
  "pipeline_id": "pipe_cmRoutine42",
  "pipeline_slug": "weekly-changelog",
  "status": "completed",
  "mode": "run",
  "started_at": "2026-05-18T08:00:00.123456Z",
  "ended_at": "2026-05-18T08:00:18.555432Z",
  "current_step_id": "",
  "output": "Drafted changelog for sprint 42",
  "cost_usd": 0.0241,
  "duration_ms": 18432,
  "error_message": "",
  "failed_at_step": "",
  "error_fingerprint": "",
  "triggered_via": "manual",
  "triggered_by_id": "",
  "idempotency_key": ""
}
error_message is sanitised in this view (single-line, ≤200 chars). The full error stays in journal_entries.

List active runs (in-memory registry)

GET /api/v1/workspaces/{workspaceId}/pipelines/runs/active
Returns the in-flight run set scoped to this workspace from the in-memory RunRegistry. Used by the dashboard’s “running now” badge and cancel buttons. Single-instance scope — in a multi-replica deployment, each replica only sees its own runs until a shared registry lands. Returns an empty list when the registry is not wired. Response: 200 OK
[
  {
    "run_id": "run_cm9abc",
    "workspace_id": "ws_cm1abc123",
    "pipeline_id": "pipe_cmRoutine42",
    "pipeline_slug": "weekly-changelog",
    "concurrency_key": "weekly-changelog:main",
    "started_at": "2026-05-19T08:00:00.123456Z",
    "cancel_requested": false
  }
]

List workspace runs (cross-pipeline feed)

GET /api/v1/workspaces/{workspaceId}/pipeline-runs
Workspace-scoped run feed for the /activity page. Returns recent runs across every pipeline with enrichment (pipeline_name, issue_identifier when triggered_via=issue). Sorted by started_at DESC. Query parameters:
ParameterTypeDefaultDescription
limitinteger50Hard cap 200
statusstringFilter; active is a dashboard shortcut for running + queued + paused
sincestring (RFC 3339)created_at lower bound for cursor pagination
Response: 200 OK
{
  "rows": [
    {
      "id": "run_cm9abc",
      "pipeline_id": "pipe_cmRoutine42",
      "pipeline_slug": "weekly-changelog",
      "pipeline_name": "Weekly changelog",
      "status": "running",
      "mode": "run",
      "started_at": "2026-05-19T08:00:00.123456Z",
      "ended_at": "",
      "current_step_id": "review",
      "step_outputs": { "draft": "..." },
      "cost_usd": 0.012,
      "duration_ms": 4321,
      "triggered_via": "schedule",
      "triggered_by_id": "sched_cmWeekly",
      "invoking_crew_id": "",
      "invoking_agent_id": "",
      "invoking_user_id": "",
      "error_message": "",
      "failed_at_step": "",
      "issue_identifier": ""
    }
  ],
  "count": 1
}

Get pipeline run

GET /api/v1/workspaces/{workspaceId}/pipeline-runs/{runId}
Returns the persisted state of a single run from pipeline_runs, joined to pipelines and missions for human-readable enrichment. step_outputs_json is parsed server-side into an object so the UI does not have to JSON.parse twice. Response: 200 OK — single row. Includes id, workspace_id, pipeline_id, pipeline_slug, pipeline_name, status, mode, current_step_id, step_outputs (parsed), output, started_at, ended_at, error_message, failed_at_step, cost_usd, duration_ms, triggered_via, triggered_by_id, idempotency_key, inputs (parsed), and issue_identifier. Unlike the workspace-runs feed, this view does not carry the invoking_crew_id / invoking_agent_id / invoking_user_id attribution fields.
StatusCondition
404Run not found in this workspace

Cancel pipeline run

POST /api/v1/workspaces/{workspaceId}/pipelines/runs/{runId}/cancel
Pre-empts an in-flight run by triggering its context. The run loop checks ctx.Err() between steps and propagates cancellation into the agent runner, which kills the underlying CLI process. Idempotent: cancelling an already-cancelled run is a no-op (200 with the same response). Cancelling a finished run returns 404 because the in-memory registry only tracks live runs. Auth: OWNER or ADMIN (canRole "manage") — cancelling another user’s run is a manage-tier action. Response: 200 OK
{
  "run_id": "run_cm9abc",
  "cancel_requested": true,
  "cancel_requested_at": "2026-05-19T08:01:33.123Z"
}
StatusCondition
403Caller below ADMIN
404Run not found in this workspace (already finished, or never started here)
503Run registry not wired

Versions

Every save snapshots the pipeline, so you can inspect its history, fetch a specific revision, or roll the head pointer back.

List versions

GET /api/v1/workspaces/{workspaceId}/pipelines/{slug}/versions
Returns the version history (newest first) for a pipeline. Query parameters: limit (default 100). Response: 200 OK
[
  {
    "version": 4,
    "definition_hash": "f3c1...",
    "author_type": "user",
    "author_id": "user_jdoe42",
    "parent_version": 3,
    "change_summary": "Add review step before publish",
    "created_at": "2026-05-18T08:00:01.123Z"
  }
]

Get one version

GET /api/v1/workspaces/{workspaceId}/pipelines/{slug}/versions/{n}
Returns a specific version including the full DSL definition.
StatusCondition
400n is not a positive integer
404Pipeline or version not found

Rollback to a version

POST /api/v1/workspaces/{workspaceId}/pipelines/{slug}/rollback
Rolls the pipeline’s head pointer + definition_json back to the named version. History is preserved — rollback does not delete newer versions. Auth: OWNER or ADMIN (canRole "manage"). Request body:
{ "version": 3 }
Response: 200 OK — full pipeline object with the restored definition.
StatusCondition
400version missing or < 1
403Caller below ADMIN
404Pipeline or version not found

Import / export

Move a pipeline between workspaces as a portable bundle — export from one, import into another.

Export pipeline bundle

GET /api/v1/workspaces/{workspaceId}/pipelines/{slug}/export
Returns a portable crewship-pipeline-bundle/v1 document. Author identity, runtime stats, and any installation-specific data are deliberately stripped — the receiving workspace fills them in at import time. Query parameters:
ParameterTypeDescription
include_history1Include up to 500 prior versions in the bundle
Response: 200 OK
{
  "format": "crewship-pipeline-bundle/v1",
  "pipeline": {
    "name": "Weekly changelog",
    "description": "Summarise merged PRs into the public changelog",
    "slug": "weekly-changelog",
    "dsl_version": "v1",
    "definition": { /* DSL */ }
  },
  "metadata": {
    "exported_at": "2026-05-19T09:00:00Z",
    "source_workspace_id": "ws_cm1abc123",
    "definition_hash": "f3c1...",
    "head_version": 17
  },
  "history": [ /* present only when include_history=1 */ ]
}
The bundle’s metadata.head_version field is misnamed in the current build — it actually carries invocation_count. Treat it as opaque metadata.

Import pipeline bundle

POST /api/v1/workspaces/{workspaceId}/pipelines/import
Creates a pipeline from a previously exported bundle. The receiving workspace becomes the author context; the original bundle’s source workspace id is preserved on the pipeline row as imported_from_url for audit. Imports skip the save validation gate by design — a marketplace bundle is presumed to have been validated in its source workspace. Auth: MANAGER+ (canRole "create") — importing creates a new pipeline row, same privilege as Save. Request body: the bundle JSON plus an explicit author_crew_id:
{
  "format": "crewship-pipeline-bundle/v1",
  "pipeline": { /* as exported */ },
  "metadata":  { /* as exported */ },
  "author_crew_id": "crew_eng"
}
Response: 201 Created — full pipeline object.
StatusCondition
400Invalid bundle JSON, unsupported format, missing pipeline.name/pipeline.definition, missing author_crew_id
403Caller below MANAGER
409Slug already exists in this workspace
422DSL parse / validate failure (receiving workspace doesn’t have every referenced agent slug)

Approval waitpoints

When a pipeline hits a step_wait of kind approval, the run parks and a waitpoint row is created. The UI’s /inbox lists pending waitpoints; approving or rejecting wakes the parked run.

List pending waitpoints

GET /api/v1/workspaces/{workspaceId}/pipelines/waitpoints
Returns up to 200 pending waitpoints across the workspace, newest first. Response: 200 OK
[
  {
    "token": "wp_8f3c1...",
    "pipeline_run_id": "run_cm9abc",
    "step_id": "review-approval",
    "kind": "approval",
    "prompt": "Approve the drafted changelog before publishing.",
    "invoking_crew_id": "crew_eng",
    "timeout_at": "2026-05-19T10:00:00Z",
    "created_at": "2026-05-19T08:00:18Z"
  }
]

Approve / reject waitpoint

POST /api/v1/workspaces/{workspaceId}/pipelines/waitpoints/{token}/approve
Completes a pending approval. Decider identity is taken from the JWT user context. Request body:
{
  "approved": true,
  "comment": "LGTM, ship it"
}
Response: 200 OK
{ "ok": true, "approved": true }
StatusCondition
400Missing token or invalid body
409Waitpoint already decided or expired
503Waitpoint store not wired, or the wired implementation does not support completion

Schedules

Schedules fire pipelines on a cron expression. The in-process scheduler ticks every minute and skips soft-deleted rows.

List schedules

GET /api/v1/workspaces/{workspaceId}/pipeline-schedules
Response: 200 OK
[
  {
    "id": "sched_cmWeekly",
    "workspace_id": "ws_cm1abc123",
    "name": "Weekly changelog",
    "target_pipeline_id": "pipe_cmRoutine42",
    "target_pipeline_slug": "weekly-changelog",
    "target_pipeline_version": null,
    "cron_expr": "0 9 * * MON",
    "timezone": "Europe/Prague",
    "inputs": { "since": "last_monday" },
    "enabled": true,
    "last_run_at": "2026-05-12T07:00:00Z",
    "last_status": "completed",
    "last_run_id": "run_cm9abc",
    "next_run_at": "2026-05-19T07:00:00Z",
    "created_at": "2026-04-12T09:00:00Z",
    "updated_at": "2026-05-12T07:00:00Z"
  }
]

Create schedule

POST /api/v1/workspaces/{workspaceId}/pipeline-schedules
Accepts either target_pipeline_slug (UI-friendly) or target_pipeline_id (CLI-friendly). Auth: MANAGER+ (canRole "create"), or a MEMBER holding the routine.create capability. Request body:
FieldTypeRequiredDescription
namestringnoFalls back to the pipeline slug
target_pipeline_slugstringone-of
target_pipeline_idstringone-of
target_pipeline_versionintegernoPin every fire to this immutable routine version; default (null) = always head. A fire whose pinned version no longer exists fails (recorded FAILED + a failed_run inbox alert) — it never silently falls back to head.
cron_exprstringyes5-field cron
timezonestringnoIANA name (e.g. "Europe/Prague"); default UTC
inputsobjectnoStatic inputs passed on every fire
enabledbooleannoDefault true
Response: 201 Created — schedule object.
StatusCondition
400Missing cron_expr, invalid cron / timezone, pipeline not found in this workspace
403Caller below MANAGER without routine.create
503Schedule store not wired

Update schedule

PATCH /api/v1/workspaces/{workspaceId}/pipeline-schedules/{scheduleId}
Whole-row replace semantics — the caller sends the post-edit state, missing fields fall back to the existing row. This includes target_pipeline_version: an absent field keeps the existing version pin, an explicit "target_pipeline_version": null clears it (fires track head again). Auth: OWNER or ADMIN (canRole "manage"). Response: 200 OK — updated schedule.
StatusCondition
400Invalid body / cron / timezone
403Caller below ADMIN
404Schedule not found in this workspace
503Schedule store not wired

Delete schedule

DELETE /api/v1/workspaces/{workspaceId}/pipeline-schedules/{scheduleId}
Soft delete. In-flight scheduled runs finish; no new runs fire.
Auth: OWNER or ADMIN (canRole "delete"). Response: 204 No Content
StatusCondition
403Caller below ADMIN
404Schedule not found
503Schedule store not wired

Webhooks

Webhooks let external systems trigger a pipeline by POSTing to a public URL. Each webhook has its own opaque token and optional HMAC signing secret. The public dispatch entrypoint (POST /api/v1/webhooks/{token}) is not under /workspaces/... — see the Webhooks API doc.

List webhooks

GET /api/v1/workspaces/{workspaceId}/pipeline-webhooks
Returns all non-deleted webhooks. The signing_secret value is never returned outside the create response. Response: 200 OK
[
  {
    "id": "wh_cmGithub",
    "workspace_id": "ws_cm1abc123",
    "name": "github-push",
    "target_pipeline_id": "pipe_cmRoutine42",
    "target_pipeline_slug": "weekly-changelog",
    "target_pipeline_version": null,
    "token": "whk_EXAMPLE_PLACEHOLDER",
    "signing_secret_set": true,
    "inputs_template": { "branch": "{{ inputs.event.ref }}" },
    "enabled": true,
    "rate_limit_per_min": 60,
    "last_fired_at": "2026-05-19T07:55:00Z",
    "last_status": "COMPLETED",
    "last_run_id": "run_cm9abc",
    "fire_count": 42,
    "created_at": "2026-04-12T09:00:00Z",
    "updated_at": "2026-05-19T07:55:00Z"
  }
]

Create webhook

POST /api/v1/workspaces/{workspaceId}/pipeline-webhooks
Auth: MANAGER+ (canRole "create") — a webhook mints a public dispatch URL, so creation is gated at MANAGER+. Request body:
FieldTypeRequiredDescription
namestringnoFalls back to the pipeline slug
target_pipeline_slugstringone-of
target_pipeline_idstringone-of
target_pipeline_versionintegernoPin every dispatch to this immutable routine version; default (null) = always head. A dispatch whose pinned version no longer exists answers 409 — never a silent head run.
signing_secretstringnoUsed for HMAC-SHA256 verification of the X-Crewship-Signature header. If omitted, the server auto-generates a 32-byte hex secret — HMAC verification is mandatory on every webhook, so signing_secret_set is always true.
inputs_templateobjectnoMerged on top of the default {event, raw, headers} envelope when the webhook fires (the three reserved keys cannot be overridden)
enabledbooleannoDefault true
rate_limit_per_minintegernoPer-token rate limit; 0 (or unset) floors to 600/min server-side, not unlimited
Response: 201 Created — webhook object including signing_secret (this is the only time it is returned, whether supplied or auto-generated; subsequent reads return only signing_secret_set: true).
StatusCondition
400Missing/invalid pipeline reference
403Caller below MANAGER
503Webhook store not wired

Delete webhook

DELETE /api/v1/workspaces/{workspaceId}/pipeline-webhooks/{webhookId}
Soft delete.
Auth: OWNER or ADMIN (canRole "delete"). Response: 204 No Content
StatusCondition
403Caller below ADMIN
404Webhook not found in this workspace
503Webhook store not wired

Skills

These endpoints manage the workspace-scoped write surface for the skills registry. Browsing skills (GET /api/v1/skills) is not workspace-scoped and lives in the Skills API doc.

Import skill (URL or paste)

POST /api/v1/workspaces/{workspaceId}/skills/import
Imports a single SKILL.md either by URL (SSRF-validated, HTTPS-only, no loopback / private addresses) or by pasted content. Re-imports of the same slug update in place. Auth: MANAGER+ (canRole "create"). Request body:
FieldTypeRequiredDescription
urlstringone-ofHTTPS URL to a raw SKILL.md
contentstringone-ofInline SKILL.md (YAML frontmatter + markdown body)
allow_unsafe_licensebooleannoBypass the SPDX allowlist; the journal entry is upgraded to WARN severity
Response: 201 Created
{
  "skill_id": "sk_a1b2c3d4e5f6",
  "name": "github-pr-review",
  "slug": "github-pr-review",
  "created": true
}
The import response carries only these four fields (created is true for a fresh row, false for an in-place update of an existing slug). Scan status and display name are persisted on the skill row but not echoed here — fetch the skill via GET /api/v1/skills/{skillId} to read them.
StatusCondition
400Missing/both of url + content, SSRF block (private/loopback/non-HTTPS), parse failure, license rejection
403Caller below MANAGER

Bulk import from git repo

POST /api/v1/workspaces/{workspaceId}/skills/bulk-import
Walks a public git repository for SKILL.md files and upserts each through the same license-gated path as single-import. Per-skill rejections are reported in the response’s skipped list rather than failing the whole batch.
Local-path bulk import (the importer’s Paths field beyond the optional in-repo subdirectory filter) is intentionally not exposed — it would turn the endpoint into an arbitrary host-FS read primitive.
Auth: MANAGER+ (canRole "create"). Request body:
FieldTypeRequiredDescription
git_urlstringyesHTTPS git URL (no embedded credentials, no loopback)
git_refstringnoBranch / tag / commit; default = repo HEAD
pathsstring[]noSubdirectory filter inside the repo
vendorstringnoOverride the vendor field on every imported skill
allow_unsafe_licensebooleannoBypass SPDX allowlist
dry_runbooleannoWalk + validate but don’t write to the DB
Response: 200 OK
{
  "source": "git:https://github.com/acme/skills.git@main",
  "total_found": 12,
  "total_imported": 9,
  "imported": [
    { "skill_id": "sk_...", "slug": "lint-typescript", "created": true }
  ],
  "skipped": [
    { "path": "skills/old/SKILL.md", "slug": "old-thing", "reason": "license MIT-no-attribution not allowed" }
  ],
  "truncated": false
}
StatusCondition
400Missing git_url, invalid workspace
403Caller below MANAGER
502Walker failure (git clone failed, missing git binary, etc.); validation errors are echoed verbatim while raw clone-process stderr is replaced with a generic message

Generate skill (LLM)

POST /api/v1/workspaces/{workspaceId}/skills/generate
Calls Anthropic with a condensed skill-creator system prompt, validates the output against the parser, and writes it back as a fresh row with source='GENERATED'. The generated content can then be edited via the regular import flow. Requires an ACTIVE Anthropic credential of type API_KEY (a real sk-ant-... — OAuth bearers from the Claude Code login flow are rejected because they only work against claude.ai, not the Messages API). Auth: MANAGER+ (canRole "create"). Request body:
FieldTypeRequiredDescription
slugstringyesDesired skill slug (slugified server-side)
promptstringyesUser’s intent / brief
modelstringnoDefault claude-sonnet-4-6
Response: 201 Created
{
  "skill_id": "sk_a1b2c3d4e5f6",
  "slug": "extract-pdf-tables",
  "content": "---\nname: extract-pdf-tables\n...---\n## When to use\n...",
  "scan_status": "CLEAN",
  "scan_reason": "",
  "description_quality": ""
}
StatusCondition
400Missing slug or prompt, invalid JSON
403Caller below MANAGER
409Generated slug collides with an existing row
412No active ANTHROPIC / API_KEY credential for this workspace
502Upstream LLM call failed, or generated content didn’t parse as a valid SKILL.md

Delete skill

DELETE /api/v1/workspaces/{workspaceId}/skills/{skillId}
Removes a skill from the registry. Cascades to agent_skills via FK. BUNDLED skills are refused — the binary re-seeds them on every startup, so a delete is a no-op churn and creates a malicious-operator window.
Auth: OWNER or ADMIN (canRole "manage"). The skills registry is shared global state; destructive operations get the higher tier. Response: 200 OK
{ "deleted": true, "skill_id": "sk_a1b2c3d4e5f6" }
StatusCondition
400Missing skill_id path value
403Caller below ADMIN, or skill is BUNDLED
404Skill not found

preferred_language values

preferred_language accepts either the canonical English name or an ISO code. The resolver normalises both to the canonical name before storage. Supported values: Afrikaans (af), Arabic (ar), Bulgarian (bg), Bengali (bn), Catalan (ca), Czech (cs), Danish (da), German (de), Greek (el), English (en), Spanish (es), Estonian (et), Persian (fa), Finnish (fi), French (fr), Hebrew (he), Hindi (hi), Croatian (hr), Hungarian (hu), Indonesian (id), Italian (it), Japanese (ja), Korean (ko), Lithuanian (lt), Latvian (lv), Malay (ms), Norwegian (nb), Dutch (nl), Polish (pl), Portuguese (pt), Portuguese (Brazil) (pt-BR), Romanian (ro), Russian (ru), Slovak (sk), Slovenian (sl), Serbian (sr), Swedish (sv), Swahili (sw), Tamil (ta), Thai (th), Turkish (tr), Ukrainian (uk), Urdu (ur), Vietnamese (vi), Chinese (zh), Chinese (Traditional) (zh-TW). The list lives in internal/api/workspaces.go and must stay in sync with lib/languages.ts on the frontend.

See also