Skip to content

Projects API

Projects are keyed by slug, not by UUID, in every project-scoped path. A slug is 3..50 lowercase letters, digits, or hyphens and must start and end with a letter or digit. The UUID still exists as project_id in payloads, but the path parameter and the Z4J agent config both use the slug.

GET /api/v1/projects

Returns projects the authenticated user has a role in. Global brain admins are the exception: they see every active project regardless of membership, up to Z4J_ADMIN_PROJECT_LIST_CAP. When the caller uses a project-scoped bearer key, the response is further filtered to that bound project.

[
{
"id": "...",
"slug": "billing-prod",
"name": "Billing (production)",
"description": null,
"environment": "production",
"timezone": "UTC",
"is_active": true,
"default_scheduler_owner": "z4j-scheduler",
"allowed_schedulers": null,
"created_at": "...",
"updated_at": "..."
}
]
GET /api/v1/projects/{slug}

Role: viewer. Returns the same ProjectPublic shape.

POST /api/v1/projects

Role: global brain admin (is_admin=true), not merely project admin. CSRF-protected. The first admin is created through first-boot setup, not this endpoint.

{
"slug": "billing-prod",
"name": "Billing (production)",
"description": null,
"environment": "production",
"timezone": "UTC",
"default_scheduler_owner": "z4j-scheduler",
"allowed_schedulers": null
}

Field rules:

  • slug: 3..50 lowercase letters / digits / hyphens, starting and ending with a letter or digit. The request model checks only length 2..63 (422 outside it); a slug that fails the format is refused with 409 conflict, the same status as a slug already in use.
  • environment: 1..40 chars matching ^[a-z][a-z0-9_-]{0,39}$; canonical values are production, staging, development, test, but free-form is accepted so values such as staging-eu continue to work.
  • default_scheduler_owner: the scheduler that owns new schedules when the caller did not pick one. Free-form (40 char cap, lowercase pattern). Default z4j-scheduler.
  • allowed_schedulers: optional allow-list of scheduler names. null means unrestricted; an empty list is rejected. Capped at 32 entries. When set, it must contain default_scheduler_owner.
PATCH /api/v1/projects/{slug}

Role: global brain admin. CSRF-protected. Any subset of the create fields, including the slug itself. Only keys sent with a non-null value are applied, so description cannot be cleared back to null through this route; allowed_schedulers is the exception, where an explicit null removes the restriction. A slug rename takes effect immediately: saved REST URLs, bookmarks, CLI --slug values, and schedule-import manifests using the old slug start returning 404. Running agents and project-scoped API keys remain bound by project UUID and continue to work.

DELETE /api/v1/projects/{slug}

Role: global brain admin. CSRF-protected and requires fresh MFA, so this route requires a browser session rather than an API key. Delete is a soft archive: it sets is_active=false and leaves agents, tasks, events, schedules, memberships, and audit rows intact. The slug remains taken. Archiving the final active project is refused.

Archiving also disconnects the project's agents. Live WebSocket sessions close with code 4003 on every replica, the same close a revoked agent gets, so the shipped agent parks on its auth backoff. A handshake from an agent of an archived project is refused with 4401, and the long-poll routes answer 403 with error: project_inactive. The agent rows and their tokens are untouched: once is_active is true again the agents reconnect on their next retry with their existing tokens, without re-minting. Each refused handshake is audited as agent.auth.project_inactive against the agent.