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: Beareraccess token (from the OIDC device flow) — not acat_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
WorkspaceDeploymentfor historical reasons (a workspace is a Huudis tenant plus its bound runtime infrastructure). API field names usehuudis_workspace_id, which is the underlying Huudisacc_…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 failed → pending (retry) and ready → archived.
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:
- Validates the plan (not
internal). - Mints a Plugipay customer under
acc_forjio_platform(best-effort — failure doesn't block). - Inserts the row at
status = pending. - 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
planenum draws from. - Agents — the next concept, scoped under your workspace.
- Portal → Workspaces — the dashboard equivalents (workspace switcher, onboarding flow).