Manage workspaces — the top-level tenant boundary that owns crews, members, invitations, skills, pipelines, schedules, and webhooks.
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.
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
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.
Field
Type
Description
name
string
2-100 chars
slug
string
2-50 chars; must remain unique across all workspaces
preferred_language
string
Canonical name or ISO code; empty string clears the field
Response:200 OK — updated workspace object.
Status
Condition
400
Invalid field values
403
Caller is not OWNER/ADMIN
409
New 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.
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:
Field
Type
Required
Default
Description
user_id
string
yes
—
Target user’s CUID
role
string
no
MEMBER
One 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.
Status
Condition
400
Missing user_id, invalid role
402
License member limit reached
403
Caller not OWNER/ADMIN, or tried to assign ADMIN without being OWNER
404
user_id does not match any user
409
User is already a member
License limit (402 Payment Required)
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.
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:
Capability
Gates
chat
Baseline — 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.create
Create pipeline schedules (cron-driven routines)
skill.create
Generate / import skills
credential.create
Create credential rows (fresh secret material in the vault)
credential.rotate
Rotate an existing credential’s value
issue.create
File issues
memory.write
Write to agent / crew / workspace memory via /remember
Presets are named bundles for the common combinations:
Preset
Capabilities
chat
chat
power
chat, routine.create, issue.create, memory.write
admin
all seven
A member with no explicit set falls back to a role-derived default (OWNER/ADMIN → admin 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 /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
Mutates a member’s capability set. The body must contain exactly one of four mutation shapes:
Field
Type
Effect
set
string[]
Replace the entire set with these capabilities
grant
string[]
Add these to the current set
revoke
string[]
Remove these from the current set
preset
string
Apply 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):
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
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.
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:
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:
Field
Type
Required
Description
slug
string
yes
URL-safe pipeline slug
name
string
no
Display name (falls back to slug)
description
string
no
—
definition
object
yes
DSL JSON document
author_crew_id
string
no
Pin a crew context for runtime resolution; without it, runs fall back to the first crew the saving user belongs to
last_test_run_at
string
conditional
RFC 3339 timestamp (within the last 5 minutes) clearing the validation gate; required unless skip_test_gate is used
last_test_run_passed
boolean
conditional
Must be true if relying on body-trust
skip_test_gate
boolean
no
OWNER/ADMIN-only escape hatch — bypass the 5-minute validation gate entirely
Response:201 Created — full pipeline object including definition.
Status
Condition
400
Missing slug or definition, invalid JSON
401
Not authenticated
403
Role below MANAGER, or skip_test_gate requested without OWNER/ADMIN
409
Slug already exists in this workspace
422
DSL 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.
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:
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.
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:
Field
Type
Default
Description
inputs
object
{}
Input map — defaults from the pipeline’s input spec are applied for missing keys
tier_override
string
—
One of trivial / fast / moderate / smart — replaces every agent_run step’s complexity for this run only. Unknown values are silently ignored.
Free-form attribution id (e.g. an issue identifier when triggered_via=issue)
Headers:
Header
Description
Idempotency-Key
Dedupes 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.
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:
Parameter
Type
Default
Description
limit
integer
50
Hard cap 500
status
string
—
Filter 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:
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
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:
Parameter
Type
Default
Description
limit
integer
50
Hard cap 200
status
string
—
Filter; active is a dashboard shortcut for running + queued + paused
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.
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
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.
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:
Parameter
Type
Description
include_history
1
Include 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.
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.
Status
Condition
400
Invalid bundle JSON, unsupported format, missing pipeline.name/pipeline.definition, missing author_crew_id
403
Caller below MANAGER
409
Slug already exists in this workspace
422
DSL parse / validate failure (receiving workspace doesn’t have every referenced agent slug)
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.
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:
Field
Type
Required
Description
name
string
no
Falls back to the pipeline slug
target_pipeline_slug
string
one-of
—
target_pipeline_id
string
one-of
—
target_pipeline_version
integer
no
Pin 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_expr
string
yes
5-field cron
timezone
string
no
IANA name (e.g. "Europe/Prague"); default UTC
inputs
object
no
Static inputs passed on every fire
enabled
boolean
no
Default true
Response:201 Created — schedule object.
Status
Condition
400
Missing cron_expr, invalid cron / timezone, pipeline not found in this workspace
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.
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.
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:
Field
Type
Required
Description
name
string
no
Falls back to the pipeline slug
target_pipeline_slug
string
one-of
—
target_pipeline_id
string
one-of
—
target_pipeline_version
integer
no
Pin 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_secret
string
no
Used 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_template
object
no
Merged on top of the default {event, raw, headers} envelope when the webhook fires (the three reserved keys cannot be overridden)
enabled
boolean
no
Default true
rate_limit_per_min
integer
no
Per-token rate limit; 0 (or unset) floors to 600/min server-side, not unlimited
Response:201 Created — webhook object includingsigning_secret (this is the only time it is returned, whether supplied or auto-generated; subsequent reads return only signing_secret_set: true).
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.
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:
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.
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:
Field
Type
Required
Description
git_url
string
yes
HTTPS git URL (no embedded credentials, no loopback)
Walker failure (git clone failed, missing git binary, etc.); validation errors are echoed verbatim while raw clone-process stderr is replaced with a generic message
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:
Field
Type
Required
Description
slug
string
yes
Desired skill slug (slugified server-side)
prompt
string
yes
User’s intent / brief
model
string
no
Default 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": ""}
Status
Condition
400
Missing slug or prompt, invalid JSON
403
Caller below MANAGER
409
Generated slug collides with an existing row
412
No active ANTHROPIC / API_KEY credential for this workspace
502
Upstream LLM call failed, or generated content didn’t parse as a valid SKILL.md
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 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.