Start here

Getting started

Five minutes from clone to your first item.

Prerequisites

  • Bun ≥ 1.1 — curl -fsSL https://bun.sh/install | bash
  • Optional: Postgres 14+ for production. Dev defaults to Bun's built-in SQLite.

Install + run

terminal
$ git clone github.com/your/workeros && cd workeros
$ bun install
$ cp apps/web/.dev.vars.example apps/web/.dev.vars

# Apply migrations to local SQLite
$ bun run db:migrate:sqlite

# Vite + Cloudflare miniflare in one process on :5173
# (admin SPA + Worker bundled — no separate API port, no proxy)
$ bun run dev

Open http://localhost:5173/sign-up and create the first user — they auto-receive the admin role. Subsequent sign-ups get authenticated.

Your first collection

In the admin, go to Collections → New and define:

collection definition
slug: posts
fields:
  - title (text, required)
  - body (longtext)
  - published (boolean)
  - views (integer)
ownerScoped: true

Click Create. The API runs CREATE TABLE c_posts (...) against the live database — no redeploy.

Your first items

Click on posts in the list, then + New item. Type-aware inputs render based on field type (textarea for longtext, checkbox for boolean, number input for views). Save.

The owner-scoped flag auto-seeds permissions for the authenticated role: each user only reads/writes their own items. Admin sees all.

Query

http
# REST
$ curl http://localhost:5173/api/items/posts?limit=10 \
       --cookie "$(cat /tmp/cookie.txt)"

# REST with filter (DSL — same as permissions)
$ curl "http://localhost:5173/api/items/posts?filter=$(echo '{\"published\":{\"_eq\":true},\"views\":{\"_gt\":10}}' | jq -sRr @uri)&sort=-views"

# GraphQL
$ curl -X POST http://localhost:5173/api/graphql \
       -H "content-type: application/json" \
       -d '{"query":"{ posts(sort:\"-views\", limit:5) { id title views } }"}'

Generate types for your client

terminal
$ bun run workeros gen-types http://localhost:5173 --out src/types.ts

The CLI fetches /api/collections and emits one TypeScript interface per collection plus a Collections registry. Use with @workeros/client:

typescript
import { createClient } from "@workeros/client";
import type { Posts } from "./types";

const wks = createClient({ url: "http://localhost:5173" });
const r = await wks.from<Posts>("posts").list({
  filter: { published: { _eq: true } },
  sort: "-views",
  limit: 10,
});

What next

Start here

Architecture

The big picture of workeros in one page — repo shape, runtimes, and adapter layers.

Repo shape

tree
workeros/
├─ apps/
│  └─ web/                 One workspace — Hono API + Vite + React admin SPA
│                          (server/ + client/ + entries/{bun,worker,vercel,netlify}.ts)
└─ packages/
   ├─ core/                Types only (DSL, errors, adapter interfaces)
   ├─ db/                  Drizzle schemas (pg + sqlite) + dynamic-DDL applier + DSL compiler
   ├─ auth/                better-auth wrapper + plugin selection
   ├─ ui/                  shadcn radix-luma component library
   ├─ client/              @workeros/client typed SDK
   └─ cli/                 workeros CLI

Adapter pattern

Every cross-runtime concern hides behind a TypeScript interface in @workeros/core/adapters. buildContext picks the right implementation based on bindings/env.

InterfaceBunCloudflare WorkersVercel / Netlify (Node 22)
StorageAdapterfsStorage / bunS3Storager2Storage / s3FetchStorages3FetchStorage (S3 env vars required)
VectorAdapterpgvectorAdaptervectorizeAdapterpgvectorAdapter
Realtimein-proc + SSEDO (Hibernation API) → SSE bridgeSSE loads but impractical
EmailAdapterconsole/resend/sendgrid/mailgun/ses/smtpsame minus smtp (no raw TCP)console/resend/sendgrid/mailgun/ses/smtp
ImageAdapterbunImagecfImagepassthroughImage
SamlAdaptersamlifysamlify (via nodejs_compat)samlify (Node 22 native crypto)
LdapAdapterldapts— (no raw TCP)ldapts

Hybrid schema ownership

System tables (users, sessions, roles, permissions, files, activity, revisions, webhooks, flows, functions, plus auth / SSO tables) live in packages/db/src/{pg,sqlite}/schema.ts — Drizzle owns them, and they migrate via hand-written SQL under packages/db/drizzle/{pg,sqlite}/.

User collections live in physical tables whose name is whatever the collection metadata row's physical_table column says — the default is c_<tenantPrefix12>_<slug>, but adopted collections can wrap any existing table name. POST /api/collections is the single create endpoint and runs DDL only on managed collections (adopted: false); adopted: true writes the metadata row alone.

Important
applyCollection is additive only — it never drops or alters existing columns and short-circuits on adopted collections. Field removal goes through the explicit dropField function so admins can audit destructive moves.

Permission DSL — three execution paths

PathCompilerOutput
REST + GraphQL filtercompileConditionDrizzle SQL fragment, parameterized
Realtime per-event filtermatchesConditionboolean (in-memory)
Sandbox ctx.db.list/onecompileCondition (via host bridge)SQL fragment

Same operators, same variables ($user.id etc.), same logical combinators. A filter that works in one place works in the others.

Event flow

CRUD routes call publishEvent(env, channel, payload, serverCtx):

flow
┌─ items.ts route ─┐
│  POST /api/items │
│        │         │
│        ▼         │
│ publishEvent ────┼───► realtime (SSE / DO)       ◄── connected subscribers
│        │         │
│        ├───────► dispatchWebhooks ──► HMAC-signed POST to webhook.url
│        │
│        ├───────► runFlows ──────► op chain (log/webhook/email/condition)
│        │
│        └───────► runEventFunctions ──► matching trigger functions
└──────────────────┘                     in the sandbox provider

All four downstream consumers see the same event payload. Webhooks, flows, and functions are fire-and-forget — they don't block the API response.

Why these tradeoffs

  • Hybrid schema instead of all-Drizzle: matches the Directus mental model where the user's data shape evolves at runtime, but keeps system tables under static migration control.
  • Own permission DSL instead of CASL: needed something the SQL compiler could read; CASL is row-evaluation-only and didn't fit.
  • Three sandbox providers instead of one: free-tier Workers users shouldn't lose function execution; paid users shouldn't be stuck on WASM-slow QuickJS.
  • Passkey-first: phishing-resistant, hardware-backed, better UX than TOTP. TOTP plugin still available for compliance use.
Start here

Deployment

Ship the same source to Bun, Cloudflare Workers, Vercel, or Netlify.

Targets at a glance

Bun (self-host)Cloudflare WorkersVercel FunctionsNetlify Functions
DatabaseSQLite or PGD1 or Hyperdrive→PGPG (neon-http recommended)PG (neon-http recommended)
Storagelocal fs / S3 / Bun.S3ClientR2 (S3 fallback)S3 (required)S3 (required)
Realtimein-proc + SSEDurable Objects + WSloads but impracticalloads but impractical
SAMLyesyes (nodejs_compat)yesyes
LDAP / SMTPyes503 (no raw TCP)yes (Node 22)yes (Node 22)
SandboxBun workerQuickJS / remote HTTPQuickJS / remote HTTPQuickJS / remote HTTP
CostVPS$0–5/mo$0–20/mo$0–19/mo

Bun (self-host)

terminal
$ APP_URL=https://your.app \
  DATABASE_URL=postgres://user:pass@host:5432/workeros \
  AUTH_SECRET=$(openssl rand -hex 32) \
  bun run --cwd apps/web dev:bun

For a managed process: systemd unit, Docker, or pm2. The Bun scheduler boots inside apps/web/src/server/entries/bun.ts; cron functions tick every 30 seconds.

Cloudflare Workers

apps/web/wrangler.toml covers the bindings. First-time setup:

terminal
$ cd apps/web

$ wrangler d1 create workeros           # paste id into wrangler.toml
$ wrangler r2 bucket create workeros-files
$ wrangler vectorize create workeros-embeddings --dimensions=1536 --metric=cosine

$ wrangler secret put AUTH_SECRET
$ wrangler secret put RESEND_API_KEY          # or SENDGRID_API_KEY / etc.

$ wrangler d1 migrations apply workeros --remote
$ wrangler deploy
Recommended
Connect the GitHub repo from the Cloudflare dashboard and let every push to main auto-deploy. No GitHub Actions workflow is needed.

Vercel

vercel.json at the repo root deploys both admin (static) and API (edge).

terminal
$ vercel link
$ vercel env add DATABASE_URL    # Postgres URL (Neon recommended for edge)
$ vercel env add AUTH_SECRET
$ vercel deploy --prod

Cron triggers (* * * * * in vercel.json) hit /api/_cron/tick and call the same cronTick the Bun scheduler uses.

Netlify

netlify.toml at the repo root mirrors the Vercel layout — admin SPA + edge function for /api/* + scheduled function for cron.

terminal
$ netlify init
$ netlify env:set DATABASE_URL postgres://...
$ netlify env:set AUTH_SECRET $(openssl rand -hex 32)
$ netlify deploy --prod
Data

Collections

Dynamic schema. Each collection becomes a physical c_<slug> table at runtime — drop or alter via the admin UI without writing migrations.

What's a collection

A collection is a metadata row plus a physical table. The metadata row lives in the collections system table and stores the slug, owner-scope flag, default sort, display template, and the registered field list. The physical table — typically c_<tenantPrefix12>_<slug> — holds the actual data.

POST /api/collections is the single create endpoint. It runs DDL on managed collections (adopted: false) and writes the metadata row alone on adopted ones.

Creating a collection

http
POST /api/collections
{
  "slug": "posts",
  "ownerScoped": true,
  "singular": "Post",
  "plural": "Posts",
  "displayTemplate": "{{ title }}",
  "fields": [
    { "name": "title",     "type": "text",     "nullable": false },
    { "name": "body",      "type": "longtext" },
    { "name": "published", "type": "boolean", "default": false },
    { "name": "views",     "type": "integer" }
  ]
}

The API runs CREATE TABLE c_posts (...), registers each field, and returns the freshly-stored metadata row. From this point on:

  • The REST endpoint /api/items/posts is live.
  • The GraphQL schema includes posts + a matching create / update / delete mutation.
  • The realtime channel items:posts accepts subscribers.
  • Webhooks / flows / functions can register on items.posts.created etc.

Adding fields

Each call appends one column via ALTER TABLE c_posts ADD COLUMN .... The applier is additive only — it never drops or modifies existing columns. Field removal is a separate explicit operation (dropField).

http
PATCH /api/collections/posts
{
  "fields": [
    /* existing fields preserved here */
    { "name": "reading_time_minutes", "type": "integer", "default": 0 }
  ]
}

Field types

FieldTypeSQL (PG)SQL (SQLite)Notes
texttexttextDefault for short strings
longtexttexttextRenders textarea in admin
integerintegerinteger
numberdouble precisionrealFloating-point
booleanbooleaninteger (0/1)
jsonjsonbtextStored as JSON string on SQLite
timestamptimestamptztext (iso-8601)
uuiduuidtext
relationuuidtextStores the target row's id
filetexttextStores the file's key

System columns

Every c_<slug> table automatically gets:

  • id — UUIDv7 primary key (ULID-shaped on SQLite)
  • created_at, updated_at — server-stamped timestamps
  • owner_id — set on create if the collection is ownerScoped
  • tenant_id — multi-tenant isolation column

These names are reserved — user-defined fields cannot reuse them.

Data

Adopting existing tables

Point a collection at a table you already own — no DDL, no migrations.

workeros normally creates a fresh physical table (c_<slug>) for every collection. Adoption is the opposite path: you point a collection at a table you already have and workeros wraps it with permissions, the items API, realtime, and the admin UI — without writing a single DDL statement against your data.

When to adopt vs create

Adopt when another app already writes to the table, when you want your own migration tooling to stay the only thing that touches DDL, or when you want workeros on top of legacy data without backfilling. Use a normal managed collection when the table doesn't exist yet or when admin-UI field add/remove should alter the physical table.

Limits and prerequisites

Primary key. Single-column only: uuid, text / varchar, integer, or bigint. Composite PKs, views, and partitioned tables are rejected at inspect.

Supported column types.

Source (PG / SQLite)workeros FieldType
varchar(n) / text (n ≤ 255)text
varchar(n) / text (n > 255)longtext
integer / bigint / smallintinteger
numeric / real / doublenumber
booleanboolean
json / jsonbjson
timestamp / timestamptztimestamp
uuiduuid

Arrays, enum types, geometry/PostGIS, and custom domains aren't mapped — inspect flags them, the wizard hides them, and they don't surface in the items API. The table itself can still be adopted.

Reserved column names. id, tenant_id, created_at, updated_at, owner_id. If your table uses one of these the column has to match its workeros role (e.g. id must be the PK). Conflicts surface as warnings on inspect.

The 3-step wizard

The admin lives at Collections → Adopt existing. Each step maps to one admin endpoint, so the flow scripts cleanly too.

1. Pick a table

http
GET /api/admin/adopt/tables

→ {
  "data": [
    { "name": "legacy_articles", "columns": 14, "rowCount": 4280 },
    { "name": "newsletter_subs", "columns": 6,  "rowCount": 12482 },
    { "name": "sessions",        "columns": 6,  "disabled": "reserved by workeros (auth)" }
  ]
}

2. Inspect & map columns

http
POST /api/admin/adopt/inspect
{ "table": "legacy_articles" }

→ {
  "data": {
    "pk": { "column": "id", "dbType": "uuid", "supported": true },
    "columns": [
      { "name": "title", "dbType": "text", "suggested": "text" },
      /* … */
    ],
    "fks": [
      { "column": "author_id", "referencesTable": "wp_users", "referencesColumn": "id" }
    ]
  }
}

3. Confirm metadata + adopt

http
POST /api/collections
{
  "slug": "articles",
  "adopted": true,
  "physicalTable": "legacy_articles",
  "pkColumn": "id",
  "ownerScoped": false,
  "fields": [ /* user-mapped */ ]
}
No DDL
applyCollection short-circuits on adopted: true — workeros never runs CREATE TABLE or ALTER TABLE against your data. Field changes via the admin do not persist on adopted collections.
Data

Querying items

One Directus-shaped URL for filter, search, sort, projection, pagination, and counts.

GET /api/items/<slug> is workeros' Directus-shaped REST query endpoint: one URL covers filter, full-text search, sort, projection, pagination, count metadata, and locale projection. Parsing lives in apps/web/src/server/lib/query.ts::parseQuery; the same compile path backs the GraphQL resolver.

The shape

ParamTypeDefaultDoes
filterJSON-encoded DSL conditionnullRow predicate; AND'd with the role's permission whereSql
qstringFree-text _contains across every readable text/longtext field
sortcomma-separated field listdefault_sort or -created_at- prefix = DESC; multi-column
fieldscomma-separated field listall readableSQL-level projection; system columns always re-added
expandcomma-separated relation fieldsInline-expand each named relation
limitinteger 1–20050Page size
offsetinteger ≥ 00Page offset
metafilter_count, total_count, *Adds extra SELECT COUNT(*) to response.meta
localestring or *nullProjects i18n_text fields to one locale

Filters

filter=<JSON> takes the same mini-DSL as a role's permission.condition. The compiler emits Drizzle SQL fragments (parameterized) and never string-concatenates user input.

Operators

OpMeansExample
_eqequal{ "status": { "_eq": "published" } }
_neqnot equal
_inin array{ "status": { "_in": ["a", "b"] } }
_ninnot in array
_gt / _gte / _lt / _ltenumeric / lexical{ "views": { "_gt": 100 } }
_nullis / is-not null{ "deleted_at": { "_null": true } }
_containsLIKE %x%{ "title": { "_contains": "foo" } }
_starts_with / _ends_withLIKE x% / %x

Logical combinators

Top-level keys are an implicit $and. $and, $or, and $not nest freely.

json
{
  "$or": [
    { "owner_id": { "_eq": "$user.id" } },
    { "published": { "_eq": true } }
  ]
}

Expanding relations

http
GET /api/items/posts?expand=author,category&limit=5

→ {
  "data": [{
    "id": "01HZ…",
    "title": "…",
    "author": { "id": "u_01", "name": "Rana" },
    "category": { "id": "c_01", "slug": "engineering" }
  }]
}

Each named relation is replaced inline with the target row, permission-filtered. The cost is one extra SELECT ... WHERE id IN (...) per relation, not N+1.

Counts

http
GET /api/items/posts?meta=filter_count,total_count

→ {
  "data": [ /* page */ ],
  "meta": {
    "filter_count": 42,    // rows matching the filter + permission
    "total_count":  1248   // rows the user can read at all
  }
}
Data

GraphQL

Auto-generated schema from your collections, with queries, mutations, and the same permission DSL.

GraphQL is exposed at /api/graphql. The schema is built at boot from /api/collections + the system tables — there's no SDL file to maintain. Every managed and adopted collection becomes a queryable type with matching create / update / delete mutations, permission-checked on every resolver.

Queries

graphql
query LatestPosts {
  posts(sort: "-views", limit: 5, filter: { published: { _eq: true } }) {
    id
    title
    views
    author {
      id
      name
    }
  }
}

The filter, sort, limit, and offset arguments accept the same shapes the REST endpoint does. Relations are resolved by nested selection (no ?expand needed).

Mutations

graphql
mutation Publish($id: ID!) {
  update_posts(id: $id, data: { published: true, published_at: "now" }) {
    id
    published
    published_at
  }
}

mutation {
  create_posts(data: { title: "hi", body: "…" }) { id }
}

mutation {
  delete_posts(id: "01HZ…") { ok }
}

Introspection

Introspection is enabled in dev and disabled in prod unless GRAPHQL_INTROSPECTION=1 is set. The auto-generated SDL is also downloadable from /api/graphql/sdl for tooling.

Access

Permissions DSL

JSON permission language shared by REST, GraphQL, realtime filters, and DB persistence.

workeros has its own JSON permission language. The same DSL is used for:

  • Role permission condition field (DB persistence)
  • REST ?filter=... query string
  • GraphQL filter argument

Compiler emits Drizzle SQL fragments (parameterized) for the SQL path, and an in-process matcher (matchesCondition) for realtime / sandbox.

Operators

The full set of comparison operators (_eq, _neq, _in, _nin, _gt, _lt, _null, _contains, _starts_with, _ends_with) is documented in the Querying items page.

Variables

Resolved against the request's auth subject:

  • $user.id — current user's id (null when anonymous)
  • $user.email
  • $user.roles — array of role names
  • $tenant.id (aka $user.tenant_id) — active workspace id
  • $nowDate.now()
$user.id resolving to null short-circuits comparison ops to false, so anonymous users never accidentally match { owner_id: { _eq: "$user.id" } }.

Permission rows

Each row in permissions binds a role to a (collection, action) pair:

json
{
  "role_id": "<authenticated-role-uuid>",
  "collection": "posts",
  "action": "read",
  "condition": { "$or": [
    { "published": { "_eq": true } },
    { "owner_id":  { "_eq": "$user.id" } }
  ] },
  "fields": ["title", "body", "published", "views"]
}
  • collection: "*" matches every collection.
  • action: one of read | create | update | delete.
  • condition: null → no row-level filter (full access).
  • fields: null → all fields readable/writable. Otherwise allow-list.

Resolution flow

  1. loadRolesForUser — fetch the user's roles. Anonymous = public; any signed-in user gets authenticated implicitly.
  2. If any role has admin: true → bypass all checks.
  3. Else find permission rows matching (role IN roles) AND action AND collection IN (slug, '*').
  4. None found → 403.
  5. OR-combine the conditions across matching rows (most permissive wins). null condition on any matching row = unrestricted.
  6. Field allow-list = union of fields across matching rows.

The compiled whereSql is AND'd with any user-supplied filter from the request, so users can never widen their access via filter.

ownerScoped: true shortcut

When a collection is created with ownerScoped: true, the API auto-seeds four permissions for the authenticated role:

ActionCondition
read{ owner_id: { _eq: "$user.id" } }
create(none — ownership set by route)
update{ owner_id: { _eq: "$user.id" } }
delete{ owner_id: { _eq: "$user.id" } }

These are real permission rows — admin can edit them in /settings afterward.

System roles

RoleBypassAuto-assigned
adminyesFirst user to sign up
authenticatednoImplicit on every signed-in request
publicnoAnonymous requests

System roles cannot be deleted from the admin UI.

Access

Auth planes & workspace end-users

The control-plane vs app-plane split — admins running the dashboard, and the end-users of whatever product is built on top of a workspace.

workeros is built for two distinct audiences inside the same instance:

  • The team running the dashboard — operators, developers, internal admins. They sign into app.your-workeros.com, configure collections, write permissions, invite teammates.
  • The end-users of whatever product is being built on top of a workspace — the customers of a SaaS, the readers of a CMS, the members of a community. They never see the dashboard. They only talk to the workspace's own auth surface and its data API.

The two-plane model

auth.plane (set on c.var.auth by session.ts middleware) tags every authenticated request with one of two values:

PlaneWhoTablesAuth surfaceCookie / token
"platform"Admins running the dashboardusers, sessions, accounts, verifications, passkeys/api/auth/* (better-auth)better-auth.* cookies + PAKs (pak_…)
"app"Workspace customersapp_users, app_sessions, app_accounts, app_verifications, external_identities/api/t/<slug>/auth/* (per-workspace better-auth)Authorization: Bearer <token>

Both planes share the same tenants table and the same role / permission machinery. The plane tag flows from sessionMiddleware into tenantMiddleware into requirePermission. Every downstream check can branch on it without re-asking "which kind of user is this".

Isolation
tenant.ts:204 pins an app-plane request to the workspace stamped on its session row and ignores any X-Workeros-Tenant header — a customer's frontend can never accidentally walk into another tenant's data even if it sends one.

Sign-in flows

Platform users sign in at /sign-in. The default surface is email + password; the workspace can enable OAuth (Google / GitHub / Apple), magic link, OTP, passkey, SAML 2.0 SSO, or LDAP. First user to sign up auto-gets the admin role.

App users sign in at /t/<slug>/sign-in. Each workspace runs its own better-auth instance with its own provider list, its own app_users table, and its own access + refresh tokens. The workspace owner picks which providers to expose.

Access

SSO & LDAP

Per-tenant SAML 2.0 and LDAP / Active Directory sign-in, configured from the admin.

SAML 2.0

SAML providers are configured per-workspace. The SP metadata is served from /api/t/<slug>/auth/saml/<provider>/metadata and the ACS endpoint at /api/t/<slug>/auth/saml/<provider>/acs.

The adapter (samlify) works on every runtime — Workers via nodejs_compat, Vercel / Netlify via Node 22's native crypto.

http
POST /api/admin/saml/providers
{
  "name": "okta",
  "entityId": "https://your-okta.example.com/app/abc",
  "ssoUrl":   "https://your-okta.example.com/app/abc/sso/saml",
  "x509Cert": "MIIDp…",
  "attributeMapping": {
    "email": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
    "name":  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
  }
}

LDAP / Active Directory

Per-tenant LDAP config under /api/admin/ldap-config. The ldapts adapter is used on Bun, Vercel, and Netlify; Cloudflare Workers fall through to a throwing shim (no raw TCP) — use SAML there.

Secrets are write-only: PATCH sets the bind password, GET returns the rest of the config without it. The workspace's /api/t/<slug>/auth/ldap/sign-in endpoint accepts { username, password } and returns the same access + refresh token pair as any other app-plane sign-in.

Runtime

Realtime

Permission-aware change feed over SSE, with a Durable Object bridge on Workers.

Permission-aware change feed over Server-Sent Events. The client transport is always SSE (EventSource) — on Cloudflare Workers the route bridges an internal Durable Object WebSocket into an SSE response, so admin / SDK code never speaks raw WebSocket.

Channels

ChannelAuthFilteredWhat lands on it
items:<slug>session/keyyescreated/updated/deleted for the collection
collectionsadmin onlyyesSchema events
presence:<name>signed-innoRoster of currently connected members
anything elsenonenoFree-form pub/sub

System channels (items:*, collections, presence:*) reject external publish — events come from the API itself when CRUD routes fire (or, for presence, on join/leave).

Subscribing

typescript
const es = new EventSource("/api/realtime/items:posts/subscribe", {
  withCredentials: true,
});

es.addEventListener("message", (ev) => {
  const e = JSON.parse(ev.data); // { event, data }
  switch (e.event) {
    case "created": appendToList(e.data); break;
    case "updated": updateInPlace(e.data); break;
    case "deleted": removeById(e.data.id); break;
  }
});

How filtering works

When a subscriber connects, the API resolves their read permission for the target collection. Three pieces are stored with the connection:

  1. authSubject{ userId, email, roles }
  2. fieldAllowlist — projected onto each event before delivery
  3. compiled in-memory matcher (matchesCondition) for the role's read condition

For every publishEvent, the matcher is run against the row. If it returns false, the subscriber never sees the event. If it returns true, the row is projected to the field allow-list before being serialized.

Hosting

Bun: in-process subscriber map. Fan-out is synchronous, no extra service. Multi-instance Bun deploys lose cross-instance fan-out — accept that or move to Workers.

Workers: a per-channel Durable Object holds the subscriber WebSockets. The HTTP route opens an internal WebSocket to the DO and pipes messages out as SSE — clients see one transport regardless.

Vercel / Netlify: SSE loads but is impractical — Lambda is stateless and function execution caps the stream. Use Workers for production realtime.

Runtime

Storage

Per-tenant object storage with ACLs, image transforms, and signed URLs on fs, R2, or S3.

Object storage with per-tenant isolation, public/private ACL, on-the-fly image transforms, and short-lived signed URLs. Runs on local fs (Bun dev), Cloudflare R2 (Workers), or any S3-compatible bucket (AWS, R2, B2, MinIO, DigitalOcean Spaces, Wasabi).

Adapter selection

ConditionAdapter
env.R2 binding presentr2Storage (Workers)
env.S3_BUCKET set on BunbunS3Storage (Bun.S3Client)
env.S3_BUCKET set elsewheres3FetchStorage (aws4fetch)
otherwisefsStorage("./.data/files") (dev)

Tenant prefix

Every physical key on disk / in the bucket is prefixed with tenants/<tenant-id>/. The API hides this — clients use logical keys (uploads/photo.jpg), and the route adds/strips the prefix. Two tenants can reuse the same logical key without colliding either in the bucket or in files.key.

Logical keys starting with tenants/ are rejected with VALIDATION so clients can't sneak into another workspace.

Endpoints

MethodPathPermissionNotes
GET/api/storagesystem_files.readList files (filterable by ?prefix=)
PUT/api/storage/:keysystem_files.createStream body to storage; row upserted
GET/api/storage/:keysystem_files.read or ?token=Stream object — with image transform params
PATCH/api/storage/:keysystem_files.updateBody: { acl?, folderId? }
DELETE/api/storage/:keysystem_files.deleteRemoves object + row
POST/api/storage/:key/signsystem_files.readReturns { url, expiresAt }

Image transforms

ParamRange / values
width1–4096 (integer)
height1–4096 (integer)
quality1–100 (ignored for lossless)
formatwebp | jpeg | png | avif
fitcover | contain
focal"x,y" with x/y in 0–100 (percent)

Invalid params throw VALIDATION (HTTP 422) — silently dropping bad input would let UI bugs ship without notice.

Runtime

Functions / sandbox

User-uploaded JavaScript that runs server-side in an isolated sandbox.

Three providers

A selector in apps/web/src/server/services/sandbox/index.ts picks one based on the runtime + bindings:

PriorityProviderRuntimeAsync ctx.*Isolation
1remote-httpany (FUNCTIONS_EXEC_URL set)yes (HTTP RPC)separate process / host
2bun-workerBunyes (postMessage RPC)Worker thread (soft)
3quickjsanywhere (fallback)noWASM (true)

quickjs stays as the cross-runtime safety net — Workers / Vercel / Netlify users still get sandbox execution (sync only) without standing up a separate executor service.

Triggers

TriggerPatternFires on
http(none)POST /api/functions/:name/invoke (admin only)
eventitems:posts:*Any publishEvent whose channel/event matches
cron*/5 * * * *Bun scheduler / Workers triggers / Vercel cron

Pattern matching: * is a wildcard segment. items:posts:* matches items:posts:created, items:posts:updated, etc.

The ctx surface

typescript
ctx.data       // input payload (request body for http; { event, data } for event)
ctx.user       // { id, email, roles[] } — null/anon for cron
console.log    // captured into the result's logs[]

// Async (bun-worker / remote-http only):
await ctx.fetch(url, init?)        // RPC; allow-list via FUNCTIONS_FETCH_ALLOW
await ctx.db.list(slug, query?)    // permission-checked read
await ctx.db.one(slug, id)         // single by id, permission-checked
await ctx.email.send({ to, subject, text, html? })

The sandbox returns whatever the user code returns; arrays / objects / primitives are JSON-serialised. Errors are caught and returned as { ok: false, error }.

Host bridge

The host bridge (apps/web/src/server/services/sandbox/host-bridge.ts) is the single dispatcher for ctx.fetch / ctx.db / ctx.email. bun-worker calls it in-process; remote-http calls it over HTTP at /api/_internal/sandbox-rpc with a Bearer token. Both paths funnel through the same permission pipeline.

Tooling

SDK & CLI

The @workeros/client typed fetch wrapper and the workeros CLI for project scaffolding.

@workeros/client

Typed fetch wrapper, browser + Node.

typescript
import { createClient } from "@workeros/client";

const wks = createClient({
  url: "https://api.your.app",
  // For server-to-server / CI; browser apps use the cookie session:
  apiKey: process.env.WORKEROS_API_KEY,
});

// CRUD
const list = await wks.from<Posts>("posts").list({
  filter: { published: { _eq: true } },
  sort:   ["-views", "title"],
  fields: ["id", "title", "views"],
  limit:  25,
  meta:   "filter_count",
});

const one     = await wks.from<Posts>("posts").one("uuid");
const created = await wks.from<Posts>("posts").create({ title: "hi" });
await wks.from<Posts>("posts").update("uuid", { views: 42 });
await wks.from<Posts>("posts").delete("uuid");

// Realtime (SSE)
const off = wks.subscribe<Posts>("items:posts", (e) => {
  console.log(e.event, e.data);
});

// Storage
await wks.storage.put("avatars/me.png", file, "image/png");

workeros CLI

CommandDoes
workeros migrateApply pending hand-written SQL migrations (pg + sqlite)
workeros gen-types <url>Fetch /api/collections and emit one TS interface per collection
workeros adopt <slug> <table>Run the 3-step adopt wizard from the terminal
workeros seedRun ./scripts/seed.ts if present

gen-types output

typescript
// src/types.ts — generated
export interface Posts {
  id: string;
  title: string;
  body: string | null;
  published: boolean;
  views: number;
  created_at: string;
  updated_at: string;
}

export interface Collections {
  posts: Posts;
  authors: Authors;
  /* … */
}
Tooling

MCP server

Expose the workspace to Claude Desktop, Cursor, and other IDE agents via the Model Context Protocol.

workeros ships a built-in MCP server at /api/mcp. It exposes your collections, items, storage, and roles as tools — meaning Claude Desktop, Cursor, Continue, and any other MCP client can read and write data through the same permission pipeline as your app.

Connecting from Claude Desktop

Generate an API key from the admin (Settings → API keys, prefix pak_), then add to your Claude Desktop config:

json
{
  "mcpServers": {
    "workeros": {
      "command": "npx",
      "args": ["-y", "@workeros/mcp-bridge"],
      "env": {
        "WORKEROS_URL":    "https://your-instance.workeros.app",
        "WORKEROS_API_KEY": "pak_..."
      }
    }
  }
}

Tools exposed

ToolDoes
list_collectionsReturn the schema of every collection the API key can read
read_itemsQuery a collection with the full filter / sort / pagination shape
create_item / update_item / delete_itemMutate items (permission-checked per call)
read_file / upload_fileRead / write objects in storage
describe_rolePretty-print a role's permission rows + condition DSL
Permissions
MCP traffic runs through requirePermission like any other call. Give the API key a narrow role if you don't want the IDE agent to be able to drop a table.
Tooling

Advisor

Automated lint over schema, permissions, and configuration. Like ESLint, for your workspace.

Advisor runs a set of static checks against your live workspace and surfaces findings in the admin (and via GET /api/admin/advisor). It is read-only — fixes are always proposed, never applied.

Categories

  • Security — over-broad permissions, public-write collections, leaked secrets in flow definitions.
  • Performance — missing indexes on frequently-filtered columns, sequential scans on large tables, JSON fields used in filter.
  • Data integrity — nullable foreign keys without ON DELETE rules, columns with mismatched types between collection metadata and physical table.
  • Hygiene — collections without any rows in 90 days, webhooks with consistently failing deliveries, API keys not used in 60+ days.

Score

The endpoint returns a numeric score in 0–100 alongside the finding list. Score weights are configurable in apps/web/src/server/services/advisor.ts — the default weighting puts security findings at 3× the impact of hygiene ones.