Workspaces

A workspace is the top-level tenant container in Catentio. Every agent, run, project, memory entry, tool, skill, API key, integration, and webhook lives inside exactly one workspace, and nothing crosses the boundary.

In v1 (internal mode) most users have exactly one workspace — the one tied to the HUUDIS_ALLOWED_USER_ID Huudis identity. This resource still matters: the portal lists workspaces via GET /v1/workspaces, and the SaaS-mode provisioning state machine lives here.

Auth on this resource. List/get/create/destroy accept a verified portal session or a Huudis-issued Authorization: Bearer access token (from the OIDC device flow) — not a cat_pk_* API key, which the control plane doesn't check at all (see API keys). The state-transition endpoint below is admin-only and uses a separate credential.

Workspace, deployment, tenant — same thing. The CP stores the row as WorkspaceDeployment for historical reasons (a workspace is a Huudis tenant plus its bound runtime infrastructure). API field names use huudis_workspace_id, which is the underlying Huudis acc_… identifier. The terms are interchangeable; we're standardising on workspace in docs.

The workspace object

{
  "huudis_workspace_id": "acc_01HX9C2K3M4N5P6Q7R8S9T0V1W",
  "customer_id": "01hx9c2k3m4n5p6q7r8s9t0v1w",
  "plan": "starter_hosted",
  "status": "ready",
  "runtime_base_url": "https://rt-acme.catent.io",
  "error_code": null,
  "error_message": null,
  "created_at": "2026-05-12T10:42:00.123Z",
  "ready_at": "2026-05-12T10:48:12.456Z",
  "archived_at": null,
  "config": {
    "plugipay_customer_id": "cus_01HXAB7K3M9N2P5QRS8TVWXY3Z",
    "plugipay_plan_id": "pln_01HX..."
  }
}
Field Type Notes
huudis_workspace_id string Huudis-issued tenant id, prefix acc_. Primary key. Stable forever.
customer_id string Derived slug from the Huudis id (acc_ stripped + lowercased + sanitised). Used in log lines + filesystem paths.
plan enum One of internal, free, starter_byo, starter_hosted, pro_byo, pro_hosted, scale. See Billing.
status enum pending, provisioning, ready, failed, archived. See State machine.
runtime_base_url string | null Where this workspace's runtime is reachable. null until provisioning finishes.
error_code string | null Set if status = failed.
error_message string | null Human-readable failure reason.
created_at timestamp ISO 8601 UTC.
ready_at timestamp | null When status flipped to ready.
archived_at timestamp | null When archived; null while active.
config object | null Per-deployment config blob; includes Plugipay customer + subscription ids. Only returned on the detail endpoint.

State machine

A workspace moves through a small set of states as the provisioning worker drives it. Transitions are one-way except failedpending (retry) and readyarchived.

pending  ──►  provisioning  ──►  ready  ──►  archived
   │                │
   └────────────────┴─►  failed
State What it means
pending Row exists; the provisioner hasn't picked it up yet.
provisioning Worker is creating the droplet, DB, Redis, storage, and bootstrapping the runtime container. Expect 2–10 minutes.
ready Runtime is reachable at runtime_base_url and /v1/system/info returns healthy.
failed Provisioner gave up. error_code + error_message populated. Retry from the portal.
archived Tear-down complete. Row retained for audit; will not accept new traffic.

The internal plan is reserved for the seed row (adi's dev-machine deployment) and skips provisioning — it's seeded at status = ready on first boot.

Endpoints

List workspaces

GET /v1/workspaces

Returns every deployment row in the CP's database — unlike API keys or projects, this route does not scope to the caller's customer_id server-side. The portal is responsible for intersecting the result with the caller's actual Huudis memberships before rendering it; a caller talking to this route directly gets the whole table.

const list = await catentio.workspaces.list();
ws_list = catentio.workspaces.list()
curl 'https://catent.io/api/v1/cp/v1/workspaces' \
  -H "Cookie: catentio_session=<session cookie set at login>"

Retrieve a workspace

GET /v1/workspaces/{huudis_workspace_id}

Returns the full deployment row including config. Use this to read provisioning status, the resolved runtime URL, or the Plugipay customer / subscription ids the CP persisted into config. Like the list route above, this does not check that the id belongs to the caller — any authenticated caller who knows (or guesses) a huudis_workspace_id can fetch its row. config has plaintext credentials scrubbed (***) before it leaves the CP, but the ids and status inside it are not access-controlled per-caller.

const ws = await catentio.workspaces.show('acc_01HX9C2K3M4N5P6Q7R8S9T0V1W');
ws = catentio.workspaces.show("acc_01HX9C2K3M4N5P6Q7R8S9T0V1W")
curl 'https://catent.io/api/v1/cp/v1/workspaces/acc_01HX9C2K3M4N5P6Q7R8S9T0V1W' \
  -H "Cookie: catentio_session=<session cookie set at login>"

Errors: 404 not_found if no deployment exists for the id (or it's invisible to the caller).

Create a workspace

POST /v1/workspaces

Enqueues a new deployment. The portal /onboarding flow POSTs here once the user has signed in via Huudis and picked a plan. The CP:

  1. Validates the plan (not internal).
  2. Mints a Plugipay customer under acc_forjio_platform (best-effort — failure doesn't block).
  3. Inserts the row at status = pending.
  4. Logs a provisioning enqueue event — today this is a stub; the real DO/Postgres/Redis driver lands in a later release.

Body:

{
  "huudis_workspace_id": "acc_01HX9C2K3M4N5P6Q7R8S9T0V1W",
  "plan": "starter_hosted",
  "config": { /* optional extra knobs */ }
}

A retry with the same huudis_workspace_id is naturally safe without any special header: the row is upserted by that id, and a non-archived existing row for it returns 409 already_exists rather than double-provisioning. There is no Idempotency-Key support on this — or any — catentio endpoint.

const ws = await catentio.workspaces.create({
  huudis_workspace_id: 'acc_01HX9C2K3M4N5P6Q7R8S9T0V1W',
  plan: 'starter_hosted',
});
ws = catentio.workspaces.create(
    huudis_workspace_id="acc_01HX9C2K3M4N5P6Q7R8S9T0V1W",
    plan="starter_hosted",
)
curl -X POST 'https://catent.io/api/v1/cp/v1/workspaces' \
  -H "Cookie: catentio_session=<session cookie set at login>" \
  -H "Content-Type: application/json" \
  -d '{"huudis_workspace_id":"acc_01HX9C2K3M4N5P6Q7R8S9T0V1W","plan":"starter_hosted"}'

Errors: 400 invalid_plan if you tried to create an internal row, 409 already_exists if a deployment for that Huudis id already exists, 502 if the Plugipay-side customer write failed and you opted into strict mode.

Transition state (operator only)

PATCH /v1/workspaces/{huudis_workspace_id}/state

Operator override for the state machine. Used while the managed provisioning driver is hand-driven: the operator runs the DO / PG / Redis setup, then PATCHes the row to ready with the resolved runtime_base_url.

Gated by RequireAdmin: either the X-Operator-Token header (set from OPERATOR_TOKEN env, the legacy shared secret) or a portal session that carries a valid X-Catentio-Admin HMAC (stamped by the portal's console proxy for an owner/admin user). A plain portal session cookie with no admin HMAC cannot call this — that's deliberate, so a compromised ordinary browser session can't escalate into provisioning.

Body:

{
  "status": "ready",
  "runtime_base_url": "https://rt-acme.catent.io",
  "error_code": null,
  "error_message": null
}
curl -X PATCH https://<your-catentio-control-plane>/v1/workspaces/acc_01HX.../state \
  -H "X-Operator-Token: $OPERATOR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"ready","runtime_base_url":"https://rt-acme.catent.io"}'

Errors: 404 operator_disabled if OPERATOR_TOKEN is unset on this deployment, 401 invalid_operator_token on a wrong token, 404 not_found if the row doesn't exist.

Destroy a workspace

DELETE /v1/workspaces/{huudis_workspace_id}

Tear-down. The CP runs the destroy worker in-process: it stops the runtime, deletes the droplet, drops the managed Postgres + Redis instances, removes the Spaces bucket, and flips the row to archived with archived_at set. The full sequence is in workers/destroy.py.

Returns the archived row (200 OK, same shape as GET).

await catentio.workspaces.destroy('acc_01HX9C2K3M4N5P6Q7R8S9T0V1W');
catentio.workspaces.destroy("acc_01HX9C2K3M4N5P6Q7R8S9T0V1W")
curl -X DELETE 'https://catent.io/api/v1/cp/v1/workspaces/acc_01HX...' \
  -H "Cookie: catentio_session=<session cookie set at login>"

Errors: 404 not_found, 400 cannot_destroy_internal (the seed row is protected), 502 destroy_failed with a {code,message} detail if any phase of the tear-down threw — the row is left in whatever partial state it reached so the operator can resume.

Destroy is irreversible. There's no soft-delete window. Once the droplet is gone, the workspace's agents, runs, projects, and memory are gone with it. Export anything you want to keep first.

Pairing with Plugipay

When you create a workspace under a non-internal plan, the CP also creates a Plugipay customer under the partner account (acc_forjio_platform) so future subscription writes have a target. The Plugipay customer id is stored on config.plugipay_customer_id; the plan id on config.plugipay_plan_id; the subscription id on config.plugipay_subscription_id.

If the Plugipay write fails at create time, the deployment still goes through — config.plugipay_customer_error carries the failure for later debugging, and the subscribe endpoint will mint the customer just-in-time the first time billing is hit. The reverse is also true: a DELETE /v1/workspaces/... does not cancel the Plugipay subscription. Cancel it manually via Plugipay's portal, or call /v1/billing/subscribe with a tier downgrade first.

Events

Workspace lifecycle transitions don't fire outbound webhook events in v1 — the CP doesn't run an outbox publisher for its own rows. The runtime emits run_* / project_* events; everything workspace-shaped is read by polling this endpoint.

Next

  • Billing — the plan catalog the plan enum draws from.
  • Agents — the next concept, scoped under your workspace.
  • Portal → Workspaces — the dashboard equivalents (workspace switcher, onboarding flow).