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,visibilityremains hard-pinned topublic; on/packages, results stay scoped to the caller's org.typenarrows 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 fromlatestVersion: in the CF-3 case a package's newest published version dropped the asset (e.g.1.1.0is aninstruction,1.0.0was theagent), solatestVersionis1.1.0butmatchedVersionis1.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 asnull). matchedVersionis resolved on the anonymous public/registry/searchonly. The authed, org-scopedGET /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:
- Lists candidate
@scope/nameagent packages from/registry/search?type=agent. - Reads
matchedVersionoff each result (the latest published, non-yanked version whoseassets[].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 (notlatestVersion), 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_ISSUERis unset) - Role-provider plugin system with OIDC, database, and LDAP providers
- In-memory fallback — when
DATABASE_URLis not set, MapStore is used (data lost on restart) - Single-instance — no clustering or replication