Skip to main content

API Overview

The Workloom API server provides REST endpoints for authentication, package management, and workspace operations.

Running the server

pnpm dev
# API server starts on http://localhost:4010

Authentication

All endpoints except /health require a Bearer token in the Authorization header.

In production, tokens are validated via OIDC (JWKS from the configured AUTH_ISSUER). In dev mode (when AUTH_ISSUER is not set), tokens are issued and validated locally.

Device authorization flow

# 1. Initiate device flow
POST /auth/device
{ user_code, verification_uri, device_code, expires_in, interval }

# 2. User approves (auto-approved in dev mode)

# 3. Exchange device code for tokens
POST /auth/token
{ device_code }
{ access_token, refresh_token, token_type, expires_in }

# 4. Refresh expired tokens
POST /auth/refresh
{ refresh_token }
{ access_token, refresh_token, token_type, expires_in }

# 5. Check identity
GET /auth/whoami
{ userId, orgId, role }

Package endpoints

# List / search packages
GET /packages?q=security

# Filter by asset type (see "Filtering by asset type" below)
GET /packages?type=agent

# Create a package
POST /packages
{ name, orgId, description, providers, visibility }

# Get package details
GET /packages/:name

# Create a new version (with file upload)
POST /packages/:name/versions
{ version, channel, assets, files }

# List versions
GET /packages/:name/versions

# Get version details
GET /packages/:name/versions/:version

Anonymous public registry

# Search public packages across ALL orgs — no auth. visibility is hard-pinned
# to 'public'; a client can never widen scope or pass an org.
GET /registry/search?q=security

# Filter by asset type (see "Filtering by asset type" below)
GET /registry/search?type=agent

# Public package detail (404 for missing OR private — never 403, no existence leak)
GET /registry/packages/:name

Filtering by asset type

Both list endpoints accept an optional type query parameter:

  • Authed, org-scoped: GET /packages?type=<AssetType>
  • Anonymous, public-only: GET /registry/search?type=<AssetType>

type is validated against the AssetType enum (agent, skill, instruction, workflow, prompt, hook, mcp_server, provider_config, template, policy, telemetry_schema). An unknown value is a 400 validation error — never a 500 or a malformed store predicate.

Semantics:

  • A package matches iff it has an asset of that type in any published version (not only its latestVersion). A package whose latest version dropped the asset but an older published version still carries it is returned.
  • Assets that appear only in unpublished (draft) versions do not match.
  • The predicate runs in the store, before pagination, so it composes correctly with limit/offset — a match that sorts onto a later page is returned at its matching offset.
  • An empty result is 200 [] (never a 404).
  • On /registry/search, visibility remains hard-pinned to public; on /packages, results stay scoped to the caller's org. type narrows within those scopes — it never widens them.

The matchedVersion response field (public search only)

When (and only when) type= is set, each anonymous /registry/search result carries an additional optional field:

  • matchedVersion — the latest published version whose assets bear that type, resolved per result. This is the version the anonymous agent-picker should pin. It may differ from latestVersion: in the CF-3 case a package's newest published version dropped the asset (e.g. 1.1.0 is an instruction, 1.0.0 was the agent), so latestVersion is 1.1.0 but matchedVersion is 1.0.0 — the latest version that still ships the requested type. This lets a client form the correct exact ref @scope/name@<matchedVersion> in a single anonymous call.
  • The field is omitted entirely when type= is not set — the type-omitted response is byte-identical to the pre-feature shape (an absent optional is stripped, never serialized as null).
  • matchedVersion is resolved on the anonymous public /registry/search only. The authed, org-scoped GET /packages?type= response intentionally does not carry it (the authed dashboard has no picker need; this avoids a per-result version-resolution cost on the authed path). This asymmetry is deliberate.

YP agent-definition picker contract

The type=agent filter powers the Yellow Pages (YP) agent-edit form's "definition source = Workloom" picker (v1: public packages only, via /registry/search?type=agent). The picker:

  1. Lists candidate @scope/name agent packages from /registry/search?type=agent.
  2. Reads matchedVersion off each result (the latest published, non-yanked version whose assets[].type === 'agent') and forms the exact ref @scope/name@<matchedVersion> — in a single anonymous call, no version-detail round-trip. Because the resolver picks the latest agent-bearing version (not latestVersion), this is correct even in the CF-3 case.

The resulting definition_ref must satisfy YP's exact-pin rule (WORKLOOM_REF_RE = /^@[a-z0-9-]+\/[a-z0-9-]+@\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/) — ranges/floating refs (^, ~, @latest, bare names) are rejected by YP's validateDefinitionPair. Workloom is a read-only provider here; it never writes YP. The contract is pinned by a vendored copy of that pure validator in apps/api/test/yp-contract.test.ts (drift-catch; source of truth is yellow-pages/packages/core/src/domain/agent.ts).

Deploy note — GIN index (migration 0012)

The Drizzle type predicate is a jsonb containment probe (assets @> '[{"type":"agent"}]'::jsonb) over package_versions. Migration 0012 adds a GIN index on package_versions.assets so the anonymous, rate-limited /registry/search?type=... path never seq-scans. Because Drizzle's transactional migration wrapper forbids CREATE INDEX CONCURRENTLY, migration 0012 is a hand-written sidecar SQL migration that runs outside the txn wrapper — apply it at deploy cutover (additive; concurrent build avoids a live-table lock).

Coverage note (owned gap)

The API test harness selects the store by DATABASE_URL, which the vitest/CI harness never sets — so all route tests exercise the in-memory MapStore predicate. The DrizzleStore SQL path (the @> containment + GIN index usage) has no automated coverage in this repo and is an explicit, owned coverage gap: it is verified manually against cloud, not claimed covered by CI. The two predicate implementations (SQL @> vs MapStore .some()) are one behavior with a known drift risk until a Postgres-backed integration test exists.

Workspace endpoints

# List workspaces
GET /workspaces

# Create a workspace
POST /workspaces
{ workspaceId, orgId, name, binding, targets }

# Get workspace details
GET /workspaces/:id

# Update workspace (bumps revision)
PUT /workspaces/:id
{ targets, ... }

# Get resolved manifest (desired state)
GET /workspaces/:id/manifest
{ workspaceId, revision, resolvedPackages, targets, providers }

Telemetry endpoints

# Sync activity over time
GET /telemetry/syncs?range=7d
[{ day: "Mon", syncs: 142 }, ...]

# Package adoption percentages
GET /telemetry/adoption
[{ name: "@acme/security-review", adoption: 89 }, ...]

# Provider usage distribution
GET /telemetry/providers
[{ name: "Claude", value: 72 }, ...]

# Drift rate over time
GET /telemetry/drift?range=7d
[{ week: "W1", rate: 18 }, ...]

# Failure rates
GET /telemetry/failures?range=7d
{ syncFailureRate: 2.1, syncPreviousRate: 3.4, ciFailureRate: 0.8 }

Health check

GET /health
{ status: "ok" }

OpenAPI spec

The API server exposes an OpenAPI 3.x spec for code generation:

# Interactive docs
GET /docs

# Raw JSON spec
GET /docs/json

The spec is used by @workloom/workloom-api to generate typed client hooks via Kubb.

Current state

  • PostgreSQL persistence via Drizzle ORM (configured via DATABASE_URL)
  • OIDC authentication via JWKS validation (falls back to dev-mode JWT when AUTH_ISSUER is unset)
  • Role-provider plugin system with OIDC, database, and LDAP providers
  • In-memory fallback — when DATABASE_URL is not set, MapStore is used (data lost on restart)
  • Single-instance — no clustering or replication