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


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

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

Create workspace

Provisions a new workspace and adds the calling user as OWNER in a single transaction. Auth: Any authenticated user. Request body:
Response: 201 Created — same shape as the List response item (without currentUserRole / counts).

Get workspace

Response: 200 OK — single workspace object (same fields as List) with currentUserRole populated from the JWT.

Update workspace

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.
0 behaves differently on the two audit windows than on run_retention_days, which rejects it. For an audit table, “keep this forever” is a retention decision an operator has to be able to express — so null means “use the product default” and 0 means an explicit never-delete. See Migrations — 20260810170000.
Response: 200 OK — updated workspace object.

Delete workspace

Soft-deletes the workspace and cascade-soft-deletes its crews and agents (deleted_at is set; membership join rows are removed). Deleted crews stop serving dispatches immediately; their runtime containers are reaped by the orchestrator’s idle-TTL lifecycle, the same path a single crew deletion relies on. On success the server broadcasts realtime events on the workspace channel — a crew.deleted per removed crew and a final workspace.deleted ({ id, slug }) — so connected clients redirect/refresh without a manual reload. Auth: OWNER only. The route sits behind canRole "manage" (ADMIN+) at the router, and the handler additionally requires the caller to be the workspace OWNER. Request body: Response: 200 OK{ "success": true }. Drive it from the CLI with crewship workspace delete.

Members

List the people in a workspace, add an existing user directly, provision a member by email, or remove one.

List members

Returns all members of the workspace ordered by created_at ASC, joined to users for display fields. Response: 200 OK

Add member

Adds an existing user to the workspace by user ID.
This takes a user_id and only a user_id. When all you have is an email address — whether or not it already has an account — use Create invitation: resolving an address to a user id here would hand every OWNER/ADMIN the account-existence oracle that signup no longer answers.
Auth: OWNER or ADMIN (canRole "manage"). Request body:
Response: 201 Created — the new member row.
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.

Provision member

Creates an account when the email is new, adds it to the workspace, and returns a one-time setup URL. Existing users are not given a setup URL; they must already control their account. The URL expires after seven days. The instance URL must be configured or discoverable by the server. Auth: OWNER or ADMIN (canRole "manage"). Request body: email (required), role (optional; defaults to MEMBER; one of ADMIN, MANAGER, MEMBER, VIEWER), and full_name (optional). Response: 201 Created
setup_url is an empty string for an existing account. A malformed email or role returns 400; a caller below ADMIN returns 403; an existing membership returns 409; and a missing public instance URL returns 503.

Remove member

Removes a workspace member. You cannot remove the workspace OWNER.
Auth: OWNER or ADMIN (canRole "manage"). Response: 200 OK
Removal is not just the membership row. Two other things happen, in this order:
  • Pages the member owns in this workspace are transferred to a crew first, per docs/prd/pages.md §7.1 rule 1b: the crew owning the most panels on the page, else a crew the departing member belongs to. If any page resolves to neither, the whole removal is refused with 409 and nothing changes — reassign those pages by hand and retry.
  • Every crew membership the user holds in this workspace is deleted, in the same transaction as the workspace_members row: both or neither. Crew membership grants on its own (crew-owned pages, crew credentials, and a per-crew role override folded into the effective role), so it cannot outlive the workspace membership it was predicated on.
Re-adding someone does not restore their crews. POST .../members creates a workspace membership and nothing else, so a returning member must be added back to each crew by hand, including any per-crew role override they held. This is deliberate: an override that survived a departure would silently re-elevate a user re-added at a lower role.
Crews in other workspaces are untouched — the purge is scoped through crews.workspace_id.

Change member role

Changes a member’s workspace role. Gated at MANAGER+ (canRole "create"), then subject to the role ladder enforced in the handler. Auth: MANAGER or above, plus the ladder:
  • You may only grant a role strictly below your own (roleRank[new] >= roleRank[caller] is rejected). An OWNER can therefore grant up to ADMIN, an ADMIN up to MANAGER, and so on. This means ownership is not transferable through this endpoint.
  • You may not modify a member ranked above your own.
  • The last OWNER cannot be demoted.
Request body: Response: 200 OK
Drive it from the CLI with crewship workspace member role.

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 eight capabilities: Presets are named bundles for the common combinations: A member with no explicit set falls back to a role-derived default (OWNER/ADMIN → the admin set, MANAGER → chat + routine.create + issue.create + memory.write, others → chat). That fallback is not the preset of the same name. It reproduces what the v109 migration wrote into the capability column, so a row the migration filled and a row still NULL resolve identically — and that migration ran before routine.run existed, so the fallback does not include it while the power and admin presets do. Nothing rests on the difference: every role at MANAGER+ clears the run gate on role alone. 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

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

Update member capabilities

Mutates a member’s capability set. The body must contain exactly one of four mutation shapes: 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.
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):

List all member capabilities (bulk)

Returns the resolved capabilities for every member of the workspace in one round-trip — this drives Settings → Members without an N+1 fan-out across the per-member endpoint. The roster fetches it once on load, whether or not any row is expanded, because each collapsed row summarises that member’s grants. Rows are ordered by membership created_at ASC so the roster renders stably between page loads. Auth: ADMIN or OWNER. Response: 200 OK

Invitations

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

List invitations

Returns pending (un-accepted) invitations ordered by created_at DESC, joined to users for the inviter. Response: 200 OK

Create invitation

Auth: OWNER or ADMIN (canRole "manage"). Assigning the ADMIN role requires the caller to be OWNER. Request body: Response: 201 Created — invitation object with the freshly minted hex token (this is the only time the token is returned). Invitations are redeemed at signup: when the invited address creates an account via POST /api/v1/auth/signup, every live invitation for that address is consumed inside the same transaction and the new user lands in each inviting workspace at the invited role, on top of their own default workspace. An invitation for an address that already has an account is left pending — signup is a no-op there, and redeeming it would place someone who never consented. This is also why POST .../members takes a user_id and not an email: resolving an arbitrary address would be an account-existence oracle.

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

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: Response: 200 OK
The definition field is omitted from the List response and only returned by Get pipeline.

Get pipeline

Returns the same shape as List but with the full definition (raw DSL JSON) inlined.

Delete pipeline

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

Save pipeline

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: Response: 201 Created — full pipeline object including definition.
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

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:
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

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: Headers: Response: 200 OKRunResult.

RunResult shape

List pipeline runs (journal-backed)

Returns pipeline.run.* journal entries for the named pipeline, newest first. Query parameters: Each row carries id, ts, entry_type, severity, summary, pipeline_id, run_id, and the raw payload.

List pipeline run records (projection table)

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: Response: 200 OK — array of run records. Each record:
error_message is sanitised in this view (single-line, ≤200 chars). The full error stays in journal_entries.

List active runs (in-memory registry)

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

List workspace runs (cross-pipeline feed)

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. step_outputs is deliberately not included in this feed — it can carry many KB of agent transcript/tool-result JSON per row, and this endpoint is polled every few seconds by the dashboard. Fetch Get pipeline run for a specific run id to get its step outputs. Query parameters: Response: 200 OK

Get pipeline run

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.

Run-window file changes

Lists the files that changed on a run’s crew inside the run window — the deliverable for document-processing routines. Read-time, non-invasive: the run is resolved to its crew (invoking_crew_id, falling back to the pipeline’s author_crew_id), the crew’s host files are listed over the same IPC path the agent/crew file endpoints use (agent /output + /crew/shared), and only files whose modification time falls inside the run’s [started_at, ended_at] window are returned. A still-running run uses “now” as the upper bound. Auth: read capability (any non-empty role). The resolved crew is re-validated against the workspace before its files are read (tenant guard). Response: 200 OK
A run with no resolvable/in-workspace crew returns { "crew_id": "", "files": [] }. Download a listed file via crew files download / crewship crew files get <crew_id> <path>.
This is a correlation, not proof of authorship, and deliberately does not modify the pipeline executor. Because it keys on modification time within the window, the list can also include files written by a concurrent chat session on the same crew, files from another run whose window overlapped, or files authored earlier and merely re-saved (an mtime bump with no content change). Read it as “what changed on the crew while this ran”. See the routine result CLI docs.

Cancel pipeline run

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

Versions

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

List versions

Returns the version history (newest first) for a pipeline. Query parameters: limit (default 100). Response: 200 OK

Get one version

Returns a specific version including the full DSL definition.

Rollback to a version

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:
Response: 200 OK — full pipeline object with the restored definition.

Import / export

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

Export pipeline bundle

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: Response: 200 OK
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

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:
Response: 201 Created — full pipeline object.

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

Returns up to 200 pending waitpoints across the workspace, newest first. Response: 200 OK

Approve / reject waitpoint

Completes a pending approval. Decider identity is taken from the JWT user context. Request body:
Response: 200 OK

Complete a waitpoint by token (public callback)

The public sibling of the approve endpoint (trigger.dev wait.forToken parity). An external system holding a waitpoint token completes the wait via an HTTP callback — no workspace JWT required: the high-entropy token in the path is the auth surface, the same model as the public webhook dispatch endpoint. This lets a human-in-the-loop or external-task wait be resolved by a third party (approval service, CI job, vendor webhook) rather than only through the inbox. The token and its callback_url are surfaced on the pending-waitpoints listing (internal/api/router_pipelines.go:127-131). Auth: none — the path token is the credential. The completion is attributed to the sentinel decider external-callback (not a user id) so audit queries can distinguish callback completions from inbox approvals (internal/api/pipeline_waitpoint_callback.go:61-64). Request body: optional. A bare POST with no body means “the external task finished, continue the run”.
Response: 200 OK
On success the parked run is resumed via the same path as the authed approve handler (internal/api/pipeline_waitpoint_callback.go:74-84).

Schedules

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

List schedules

Response: 200 OK

Create schedule

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: Response: 201 Created — schedule object.

Update schedule

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). Likewise wake_fail_closed: absent keeps the existing policy, an explicit boolean sets it; clearing the wake gate resets the policy to fail-open. Auth: OWNER or ADMIN (canRole "manage"). Response: 200 OK — updated schedule.

Delete schedule

Soft delete. In-flight scheduled runs finish; no new runs fire.
Auth: OWNER or ADMIN (canRole "delete"). Response: 204 No Content

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

Returns all non-deleted webhooks. The signing_secret value is never returned outside the create response. Response: 200 OK

Create webhook

Auth: MANAGER+ (canRole "create") — a webhook mints a public dispatch URL, so creation is gated at MANAGER+. Request body: 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).

Delete webhook

Soft delete.
Auth: OWNER or ADMIN (canRole "delete"). Response: 204 No Content

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)

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: Response: 201 Created
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.

Bulk import from git repo

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: Response: 200 OK

Generate skill (LLM)

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: Response: 201 Created

Delete skill

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

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