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
$ bun run db:migrate:sqlite
$ 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
$ curl http://localhost:5173/api/items/posts?limit=10 \
--cookie "$(cat /tmp/cookie.txt)"
$ curl "http://localhost:5173/api/items/posts?filter=$(echo '{\"published\":{\"_eq\":true},\"views\":{\"_gt\":10}}' | jq -sRr @uri)&sort=-views"
$ 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.
| Interface | Bun | Cloudflare Workers | Vercel / Netlify (Node 22) |
StorageAdapter | fsStorage / bunS3Storage | r2Storage / s3FetchStorage | s3FetchStorage (S3 env vars required) |
VectorAdapter | pgvectorAdapter | vectorizeAdapter | pgvectorAdapter |
| Realtime | in-proc + SSE | DO (Hibernation API) → SSE bridge | SSE loads but impractical |
EmailAdapter | console/resend/sendgrid/mailgun/ses/smtp | same minus smtp (no raw TCP) | console/resend/sendgrid/mailgun/ses/smtp |
ImageAdapter | bunImage | cfImage | passthroughImage |
SamlAdapter | samlify | samlify (via nodejs_compat) | samlify (Node 22 native crypto) |
LdapAdapter | ldapts | — (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
| Path | Compiler | Output |
| REST + GraphQL filter | compileCondition | Drizzle SQL fragment, parameterized |
| Realtime per-event filter | matchesCondition | boolean (in-memory) |
Sandbox ctx.db.list/one | compileCondition (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 Workers | Vercel Functions | Netlify Functions |
| Database | SQLite or PG | D1 or Hyperdrive→PG | PG (neon-http recommended) | PG (neon-http recommended) |
| Storage | local fs / S3 / Bun.S3Client | R2 (S3 fallback) | S3 (required) | S3 (required) |
| Realtime | in-proc + SSE | Durable Objects + WS | loads but impractical | loads but impractical |
| SAML | yes | yes (nodejs_compat) | yes | yes |
| LDAP / SMTP | yes | 503 (no raw TCP) | yes (Node 22) | yes (Node 22) |
| Sandbox | Bun worker | QuickJS / remote HTTP | QuickJS / remote HTTP | QuickJS / remote HTTP |
| Cost | VPS | $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
$ 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
$ 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
$ 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": [
{ "name": "reading_time_minutes", "type": "integer", "default": 0 }
]
} Field types
| FieldType | SQL (PG) | SQL (SQLite) | Notes |
text | text | text | Default for short strings |
longtext | text | text | Renders textarea in admin |
integer | integer | integer | |
number | double precision | real | Floating-point |
boolean | boolean | integer (0/1) | |
json | jsonb | text | Stored as JSON string on SQLite |
timestamp | timestamptz | text (iso-8601) | |
uuid | uuid | text | |
relation | uuid | text | Stores the target row's id |
file | text | text | Stores 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 / smallint | integer |
numeric / real / double | number |
boolean | boolean |
json / jsonb | json |
timestamp / timestamptz | timestamp |
uuid | uuid |
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": [ ]
} 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
| Param | Type | Default | Does |
filter | JSON-encoded DSL condition | null | Row predicate; AND'd with the role's permission whereSql |
q | string | — | Free-text _contains across every readable text/longtext field |
sort | comma-separated field list | default_sort or -created_at | - prefix = DESC; multi-column |
fields | comma-separated field list | all readable | SQL-level projection; system columns always re-added |
expand | comma-separated relation fields | — | Inline-expand each named relation |
limit | integer 1–200 | 50 | Page size |
offset | integer ≥ 0 | 0 | Page offset |
meta | filter_count, total_count, * | — | Adds extra SELECT COUNT(*) to response.meta |
locale | string or * | null | Projects 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
| Op | Means | Example |
_eq | equal | { "status": { "_eq": "published" } } |
_neq | not equal | |
_in | in array | { "status": { "_in": ["a", "b"] } } |
_nin | not in array | |
_gt / _gte / _lt / _lte | numeric / lexical | { "views": { "_gt": 100 } } |
_null | is / is-not null | { "deleted_at": { "_null": true } } |
_contains | LIKE %x% | { "title": { "_contains": "foo" } } |
_starts_with / _ends_with | LIKE 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.
http
GET /api/items/posts?meta=filter_count,total_count
→ {
"data": [ ],
"meta": {
"filter_count": 42,
"total_count": 1248
}
} 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 $now — Date.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
loadRolesForUser — fetch the user's roles. Anonymous = public; any signed-in user gets authenticated implicitly. - If any role has
admin: true → bypass all checks. - Else find permission rows matching
(role IN roles) AND action AND collection IN (slug, '*'). - None found → 403.
- OR-combine the conditions across matching rows (most permissive wins).
null condition on any matching row = unrestricted. - 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:
| Action | Condition |
| 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
| Role | Bypass | Auto-assigned |
admin | yes | First user to sign up |
authenticated | no | Implicit on every signed-in request |
public | no | Anonymous 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:
| Plane | Who | Tables | Auth surface | Cookie / token |
"platform" | Admins running the dashboard | users, sessions, accounts, verifications, passkeys | /api/auth/* (better-auth) | better-auth.* cookies + PAKs (pak_…) |
"app" | Workspace customers | app_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
| Channel | Auth | Filtered | What lands on it |
items:<slug> | session/key | yes | created/updated/deleted for the collection |
collections | admin only | yes | Schema events |
presence:<name> | signed-in | no | Roster of currently connected members |
| anything else | none | no | Free-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);
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:
authSubject — { userId, email, roles } fieldAllowlist — projected onto each event before delivery - 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
| Condition | Adapter |
env.R2 binding present | r2Storage (Workers) |
env.S3_BUCKET set on Bun | bunS3Storage (Bun.S3Client) |
env.S3_BUCKET set elsewhere | s3FetchStorage (aws4fetch) |
| otherwise | fsStorage("./.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
| Method | Path | Permission | Notes |
| GET | /api/storage | system_files.read | List files (filterable by ?prefix=) |
| PUT | /api/storage/:key | system_files.create | Stream body to storage; row upserted |
| GET | /api/storage/:key | system_files.read or ?token= | Stream object — with image transform params |
| PATCH | /api/storage/:key | system_files.update | Body: { acl?, folderId? } |
| DELETE | /api/storage/:key | system_files.delete | Removes object + row |
| POST | /api/storage/:key/sign | system_files.read | Returns { url, expiresAt } |
| Param | Range / values |
width | 1–4096 (integer) |
height | 1–4096 (integer) |
quality | 1–100 (ignored for lossless) |
format | webp | jpeg | png | avif |
fit | cover | 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:
| Priority | Provider | Runtime | Async ctx.* | Isolation |
| 1 | remote-http | any (FUNCTIONS_EXEC_URL set) | yes (HTTP RPC) | separate process / host |
| 2 | bun-worker | Bun | yes (postMessage RPC) | Worker thread (soft) |
| 3 | quickjs | anywhere (fallback) | no | WASM (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
| Trigger | Pattern | Fires on |
http | (none) | POST /api/functions/:name/invoke (admin only) |
event | items: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
ctx.user
console.log
await ctx.fetch(url, init?)
await ctx.db.list(slug, query?)
await ctx.db.one(slug, id)
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",
apiKey: process.env.WORKEROS_API_KEY,
});
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");
const off = wks.subscribe<Posts>("items:posts", (e) => {
console.log(e.event, e.data);
});
await wks.storage.put("avatars/me.png", file, "image/png"); workeros CLI
| Command | Does |
workeros migrate | Apply 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 seed | Run ./scripts/seed.ts if present |
gen-types output
typescript
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_..."
}
}
}
} | Tool | Does |
list_collections | Return the schema of every collection the API key can read |
read_items | Query a collection with the full filter / sort / pagination shape |
create_item / update_item / delete_item | Mutate items (permission-checked per call) |
read_file / upload_file | Read / write objects in storage |
describe_role | Pretty-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.