Benmore documentation
Benmore turns a data model, a little YAML config, and your frontend into a complete, hosted web app - with authentication, a typed REST + real-time API, role-based access, validation, file storage, and a generated client SDK. You describe what you want; Benmore runs it.
The mental model, in one line:
Schema in Prisma. Config in YAML. UI in TSX (auto-compiled). Server logic (when you need it) in YAML hooks, flows, and cron.
You can build entirely by describing your app in chat (connect Benmore to Claude, ChatGPT, or Cursor via MCP), from a terminal coding agent like Claude Code or Codex (the CLI), or by editing files yourself. Every app you create is hosted on <your-app>.benmore.ai with automatic HTTPS, daily backups, and per-app data isolation.
What you get, automatically
Getting started
There are two ways to drive Benmore. Both start with a free account. Per-client walkthroughs (Claude on the web, Claude Desktop, Claude Code, Codex) live in the connect guide.
1 · Chat clients (MCP)
Add Benmore as a Model Context Protocol connector in your assistant, then describe the app you want. It builds and deploys for you.
https://benmore.ai/mcp- Claude.ai - Settings → Connectors → Add custom connector, paste the URL, sign in.
- ChatGPT - Settings → Connectors → add a custom connector with the URL above.
- Cursor - add it to
~/.cursor/mcp.jsonundermcpServers.
2 · Claude Code & Codex (the CLI)
Install the CLI and your terminal coding agent builds, runs, and deploys Benmore apps from the command line.
brew install benmore-studio/benmore/benmore
benmore bootstrap # set up ~/Benmore, install the skill, sign in, sync your appsbenmore bootstrap creates your ~/Benmore workspace, installs the Claude Code skill, signs you in, and pulls any apps you've already deployed into ~/Benmore/<app>. On a new machine, benmore sync restores the whole workspace.
App anatomy
An app is a directory. Here's everything it can contain - most files are optional.
myapp/
├── app.yaml # config: theme, auth, features, access, roles, aggregates
├── schema.prisma # data model → compiled to SQLite + migrations
├── src/bm.d.ts # generated TypeScript types for your schema (don't hand-edit)
├── static/ # YOUR frontend
│ ├── index.html # served at / (CSRF meta + import-map injected)
│ ├── login.html # served at /login (clean-URL routing)
│ ├── app.tsx # entry - compiled on the fly → /static/app.js
│ └── styles.css
├── flows.yaml # optional: custom HTTP routes (or flows/*.yaml)
├── hooks.yaml # optional: before/after-CRUD side effects
├── workflows.yaml # optional: state machines
├── cron.yaml # optional: scheduled jobs
├── encrypted.yaml # optional: field-level encryption
├── emails/*.html # optional: email templates
├── i18n/<lang>.yaml # optional: translations
├── env.yaml # secrets (never deployed; set via `benmore env set`)
└── data.db # SQLite - created automaticallyThe build loop
- Describe or scaffold. Ask your AI to build something, or run
benmore new myapp. - Edit. Change
schema.prisma,app.yaml, orstatic/*.tsx. TSX recompiles on the next request (~10 ms). - Deploy.
benmore deploy(or the MCP tool) ships it to<app>.benmore.ai. Schema changes migrate automatically with a pre-migration backup. - Iterate. Every push auto-commits to the app's git history -
benmore git log/show/revertto roll back.
Data model - schema.prisma
Standard Prisma, compiled to SQLite. Every model becomes a table with a full CRUD API at /api/<table>.
model Note {
id Int @id @default(autoincrement())
title String
body String @default("")
status String @default("draft")
userId Int @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") // presence enables soft-delete
@@index([userId, updatedAt])
@@fulltext([title, body]) // enables /api/notes/search?q=...
}- Conventions: snake_case columns in SQL, camelCase in Prisma via
@map. AdduserIdto owner-scoped tables andcreatedAt/updatedAttimestamps. - The server sets
user_idfrom the session - never trust it from the client. - Soft delete: a
deletedAtcolumn makesDELETEa soft delete; reads auto-filter deleted rows. - Full-text search:
@@fulltext([...])addsGET /api/<table>/search?q=(SQLite FTS5). - UUID keys:
id String @id @default(uuid())- generated server-side, returned from POST. Use for ids in shareable links; otherwise prefer INTEGER. (Scoping, not unguessability, is the real authorization control.)
Auto-CRUD API
For every table, with no code:
| Method | Path | What it does |
|---|---|---|
GET | /api/<table> | List (auto-scoped to the caller) |
GET | /api/<table>/{id} | Single row |
POST | /api/<table> | Create (user_id auto-set); returns the full inserted row |
PATCH | /api/<table>/{id} | Partial update |
DELETE | /api/<table>/{id} | Delete (soft if a deleted_at column exists) |
POST/PATCH/DELETE /api/<table>/batch | Bulk operations | |
POST /api/<table>/ingest | NDJSON streaming ingest (rate-limited) | |
GET /api/<table>/search?q=… | FTS5 search (needs @@fulltext) | |
Protected fields - user_id, role, password_hash, created_at, updated_at are stripped from any client body; the server sets them. Dropped fields are listed in the X-Stripped-Fields response header. Mutations from cookie sessions require an X-CSRF-Token header (auto-handled by the SDK); Bearer-auth requests don't.
Querying & pagination
List endpoints accept these query params:
?orderBy=created_at:desc # sort
&limit=50&page=2 # offset pagination
&cursor=XYZ&limit=50 # keyset pagination (stable under inserts)
&where[status]=active # equality filter
&where[amount__gt]=100 # operators: __gt __lt __gte __lte __in __like
&q=keyword # FTS5 (on @@fulltext tables)
&count=true # {count: N} - true total, ignores the row cap
&as_of=2026-01-01T00:00:00Z # point-in-time (versioned tables)
&include=author # expand a relationList responses cap at 50 rows by default - use ?count=true for a true total, or paginate. Filters on encrypted columns are restricted to equality (via the blind index); range/LIKE on ciphertext is dropped rather than returning misleading results.
Versioning, soft-delete & concurrency
| Feature | How |
|---|---|
| Soft delete | Add a deleted_at column → DELETE flips it; reads hide deleted rows; restore via the SDK. |
| Optimistic concurrency | Send _expected_updated_at on PATCH; a stale value returns 409 Conflict instead of silently overwriting. |
| Content versioning | GET /api/<t>/{id}/versions, POST …/revert/{version}, and ?as_of=<iso> point-in-time reads. |
| Record locking | Pessimistic locks: POST/DELETE/GET /api/<t>/{id}/lock with automatic expiry. |
| Idempotency | Send X-Idempotency-Key on POST to make double-submits safe. |
| Audit trail | Every mutation is logged with actor + before/after values; query at GET /api/_audit (admin). |
| Retention | retention.yaml sets TTL policies that auto-clean old rows. |
Field-level encryption & blind index
Declare sensitive columns in encrypted.yaml. Values are AES-GCM encrypted at rest and unmasked only for authorized roles.
paychecks:
fields: [wages_cents, ssn] # works on TEXT, INTEGER, and REAL columns
blind_index: [ssn] # deterministic HMAC sibling → equality search
unmask_roles: [admin, payroll]- Blind index lets you still do
?ssn=…equality lookups on encrypted data (a separate HMAC key, rotated in lockstep). benmore rotate-keyre-encrypts and rebuilds every blind index atomically.- Trade-off: a deterministic index leaks which rows share a value - don't blind-index a column whose duplicate pattern is itself sensitive.
Aggregates, computed & custom fields
- Aggregates - materialized query results refreshed on a schedule. Define in
app.yamlunderaggregates:, read viabm.aggregate('name')orGET /api/_aggregates. - Computed fields -
computed.yamldefines expression/aggregate columns backed by SQLite triggers. - Custom fields - extend a table's schema at runtime via
POST /api/_custom_fields, no migration.
aggregates:
total_revenue:
sql: "SELECT SUM(amount) AS v FROM orders WHERE status='paid'"
refresh: "5m"Configuration - app.yaml
One file holds all app config.
site_name: "My App"
theme: zinc # color theme
mode: dark # light | dark
font: "Inter"
auth:
identifier: email # email | phone | username
session_duration: "30d"
signup_fields: "first_name,last_name"
require_verified: false
oauth:
google: true
roles:
viewer: "contacts:read"
manager: "contacts:*, reports:read"
admin: "*"
features:
testing: false # in-app visitor-feedback widget
analytics: true
access:
contacts: { read: self, write: self, update: self, delete: admin }
announcements: { read: anon, write: admin }
groups: # multi-tenant data isolation
table: organizations
key: org_id
user_field: org_id
seo:
description: "…"
image: "https://myapp.benmore.ai/static/og.png" # link-unfurl preview
backup: { interval: "24h", keep: 7 }Other keys: brand, nav.style, pwa.{name,icon,offline}, auth.{otp,domain,redirect,mfa}, features.{admin,sse,ws,ws_anonymous}, auto_memberships, frontend.stack (html default, or gotmpl for SSR/SEO pages).
Auth & sessions
- Sign up / sign in - email + password or Google OAuth, built in.
bm.auth.signUp/signIn/signOut/meon the client. - Sessions - HttpOnly cookies for web; Bearer tokens for API/native (
POST /api/_auth/tokenexchanges credentials for a token; sendAuthorization: Bearer <token>). - MFA - TOTP with backup codes:
bm.mfa.enroll/verify/disable. - Password reset & email verification - token flows at
/forgot-password,/reset-password; opt-in verify viaauth.verify_email. - Profile & sessions API -
GET/PATCH /api/_auth/profile, list/revoke active sessions (GET/DELETE /api/_auth/sessions). Session IDs are SHA-256 hashed before exposure. - Brute-force guard - 5 failures → 15-minute lockout.
Access control & RBAC
Authorization is enforced server-side. Set per-table modes in app.yaml's access: block:
| Mode | Who can |
|---|---|
public | Anyone, including signed-out visitors (read-only feeds) |
anon | Signed-out writes allowed (e.g. a contact form) |
self | Only the row's owner (user_id match) |
admin | Admins only |
role:a,b | Any of the listed roles |
owner_or_role:a,b | Tiered: the row's owner sees their own; a listed role sees the whole group (the common "owner sees own, manager sees more" shape) |
member-of:<table>(<join>,<user>) | Visible only to members of the parent row (membership-table EXISTS guard on every per-row surface) |
perm:<resource>.<action> | Granular permission against the unioned scope set |
- Multi-role RBAC - a user can hold many roles (join table); effective scopes are the union, with
inherits:for transitive roles. Grants can be time-bounded (expires_at) and scoped to a specific group (tenant). - Per-row ACLs - share a single record:
bm.permissions.share(table, id, email, level). - Canonical scoping - every per-row path (read/update/delete, batch, includes, workflows, locks, signed URLs) runs through one access check: admin-bypass, effective-group (act-as aware), owner, then ACL grants.
Multi-tenant groups
The groups: block isolates each customer/team/org's data. Set a key column (e.g. org_id) on tenant-scoped tables and the framework filters every query by the caller's effective group - including admins acting on behalf of a tenant. auto_memberships can auto-join every active user to rows of a parent table.
The frontend - TSX & the build pipeline
Your UI lives in static/. The framework serves files with clean-URL routing (/contacts → static/contacts.html), compiles *.tsx on the fly via embedded esbuild (no Node, no build step), and injects three things into every served HTML page:
- an import map so
import bm from 'bm'resolves to the SDK; - a CSRF meta tag the SDK reads automatically;
- cache-busting
?v=<mtime>on your<script>/<link>refs (don't add your own?v=).
Want React/Preact? import it from a CDN in your .tsx - the framework treats static/ as opaque. For SEO-critical marketing pages, set frontend.stack: gotmpl for server-rendered HTML.
The bm SDK
A thin, fully-typed wrapper over the REST/SSE/WS surface - generated from your schema, so bm.table('typo') is a compile error and per-table features drive conditional methods.
import bm, { type User, type Post, type ChangeEvent } from 'bm';
const me: User | null = await bm.auth.me();
if (!me) { location.href = '/login.html'; }
const posts: Post[] = await bm.table('posts').list({ limit: 50 });
await bm.table('posts').create({ title: 'Hello' }); // returns the new row
// Real-time: refetch when the table changes (also refetch after your own writes)
bm.live('posts', (ev: ChangeEvent<Post>) => refresh());
await bm.flows.publishPost({ id: posts[0].id }); // custom flow, typed
const total = await bm.aggregate('total_posts'); // materialized aggregate
const room = bm.room('lobby'); // WebSocket room
room.on('hello', (payload, from) => console.log(from));
room.send({ kind: 'hello' });| Method | Notes |
|---|---|
bm.auth.{me, signIn, signUp, signOut} | Session lifecycle |
bm.table(t).{list, get, create, update, delete} | Typed CRUD |
bm.table(t).{restore, versions, revertTo, list({q})} | Conditional on table features |
bm.table(t).count(opts?) | True row count (list caps at 50) |
bm.live(t, cb) · bm.live.scoped(t, fn, opts) | SSE; refetch-safe variant |
bm.room(name) | WS room - chat / presence / signaling |
bm.broadcast.{publish, subscribe, stop} | SFU livestream (1:N) |
bm.flows.<name>(params, body) | One typed method per HTTP flow |
bm.workflow(t, id).{transitionTo, available, current} | State machine |
bm.aggregate(n) · bm.aggregates.all() | Materialized aggregates |
bm.notifications.{list, markRead, onNew} | In-app inbox |
bm.upload(file, opts) | Multipart upload → {path} |
bm.api.optimistic({apply, request, snapshot, revert}) | Optimistic mutation + reconcile |
bm.presence(slug) · bm.cache.namespaced(n, v) | Live presence; self-busting cache |
bm.permissions.{share, revoke, list} · bm.signedUrl(p, ttl) | Per-row ACLs; signed URLs |
bm.markdown(t) · bm.t(key, vars) · bm.mfa.* | Safe Markdown; i18n; TOTP |
bm.api.{get, post, patch, delete} | Raw escape hatch |
Components & auto-served libraries
Drop-in script tags, served from the platform (no CDN needed): TailwindHTMXAlpineChart.jsMermaidLucide at /_internal/*. A schema-driven component layer (DataTable, RecordForm, charts, etc.) can introspect /api/_schema to render tables and forms directly from your column types.
Hooks - hooks.yaml
React to data changes. before_* hooks are synchronous and can abort a mutation; on_* hooks run async side effects (SQL, webhook, email, notify).
before_insert:
orders:
- error: "Amount must be positive"
when: "{{amount}} <= 0"
on_update:
orders:
- notify: "{{user_id}}"
title: "Order {{id}} is now {{status}}"
when: "{{status}} != {{old_status}}"
- sql: "UPDATE inventory SET qty = qty - {{qty}} WHERE sku = {{sku}}"Hook templates see session context ({{user_id}}, {{user_email}}, {{user_role}}, {{user_group_id}}) and update deltas ({{old_status}}, …).
Flows - custom HTTP routes
A flow is a named HTTP route built from ordered steps. Define in flows.yaml (or flows/*.yaml); each flow becomes a typed bm.flows.<name>() method.
publish_post:
on: { method: POST, path: /api/posts/{id}/publish }
steps:
- run: sql
query: "UPDATE posts SET status='published' WHERE id={{params.id}} AND user_id={{user_id}}"
- run: api
url: "https://hooks.example.com/notify"
method: POST
body: { post: "{{params.id}}" }
success_when: "{{ steps.notify.outputs.status }} == 200"
- run: respond
json: { ok: true, id: "{{params.id}}" }- Step types:
sql,api(outbound HTTP, SSRF-guarded),parse,compute(server JS),respond. - Transactions:
transaction: truewraps all SQL steps in BEGIN/COMMIT. - Async:
mode: asyncenqueues the flow and returns202 + {job_id, status_url}- dodges edge timeouts for long jobs. Poll withbm.jobs.status/wait. (respond:/redirect:are no-ops in async -benmore checkwarns.) - Fail loud: an
sqlstep exposessteps.<id>.outputs.rows_affected; addexpect_rows: ">0"so a guardedINSERT…WHEREthat writes nothing fails the flow instead of faking success. - Rate limit: a per-flow
rate_limit:caps calls (per-user when authed, per-IP otherwise). - Pipes:
{{ steps.x.outputs.json.items | length }}- 27 pipes available in flow/hook/cron templates.
Server-side compute
For logic that doesn't fit SQL - iterative math, financial models, scoring, simulation - a run: compute step runs a TS/JS function from static/<module>.ts server-side in a sandboxed engine (no fetch, fs, or process; 5 s timeout). The same module can import client-side for instant recompute and run server-side for the authoritative result - one algorithm, byte-identical math on both sides.
Outbound signing & inbound verify
- Sign outbound calls - a
sign:block on arun: apistep handles AWS SigV4, Stripe-style HMAC, OAuth client-credentials (TTL-cached bearer), JWT, or plain bearer. Built-ins plus an inline recipe language for custom schemes. - Verify inbound webhooks - a
verify:block onon.requestchecks the HMAC signature in constant time and rejects with 401 before any step runs (GitHub-style body HMAC, or timestamp+body with a replay-window guard).
Workflows - state machines
workflows.yaml defines states, transitions, role guards, timeouts, and on-enter hooks. Drive them with POST /api/<table>/{id}/transition (or bm.workflow(...)); GET …/transitions returns the valid next states for the current user.
orders:
field: status
states: [draft, submitted, approved, shipped]
transitions:
- { from: draft, to: submitted }
- { from: submitted, to: approved, role: manager }
- { from: approved, to: shipped, role: admin }Real-time - SSE & WebSocket
- SSE (
/sse/events) - the framework broadcasts a{table, action}event on every mutation, scoped to the relevant org/owner. The client refetches the row.bm.live(table, cb)wires it up. SSE is opportunistic - always refetch after your own writes too. - WebSocket (
/ws) - pure stdlib RFC 6455.bm.room(name)gives scoped broadcast for chat, presence, and signaling. Withfeatures.ws_anonymous, signed-out viewers can share a public room (livestream chat). - Cluster-safe - events publish through a SQLite-backed bus so they fan out across instances.
WebRTC & livestream
bm.webrtc.iceServers()- STUN/TURN config for peer-meshRTCPeerConnections (time-limited TURN credentials minted server-side).bm.broadcast.publish(slug, stream)/subscribe(slug)- SFU-backed 1:N livestream. The publisher uploads once; the SFU fans out to hundreds of viewers with sub-second latency (peer-mesh chokes at ~5).- Start viewer
<video>elementsmutedand surface a "tap to unmute" overlay - browsers block autoplay-with-sound.
Files, uploads & PDF
bm.upload(file)→{path}. Local disk by default; S3-compatible when configured. Platform-hosted apps get a CDN-backed media origin.- Signed URLs - time-limited, HMAC-signed file access via
bm.signedUrl(path, ttl). - PDF -
GET /pdf/<page>renders any page to PDF server-side (no browser). - XLSX - pure-Go spreadsheet export.
Notifications & webhooks
- In-app inbox - per-user notifications pushed over SSE/WS.
bm.notifications.list/markRead/onNew; create from hooks withnotify:. - Outbound webhooks - subscribers register a URL (
POST /api/_webhooks) for create/update/delete events. Deliveries are HMAC-SHA256 signed, retried as background jobs, and SSRF-guarded (private IPs blocked at registration and delivery).
Edges
An edge is a single self-contained HTML document hosted on its own isolated origin (edge-<slug>.benmore.ai) - a shareable mini-tool, calculator, form, or demo. Because the origin is distinct, an edge can't read the parent app's cookies or storage. The only channel back is a strict window.bm.api bridge (anonymous by default; acting as the viewer requires explicit consent against owner-declared scopes).
Dev / prod environments
One logical app can run two instances - dev (<sub>-dev.benmore.ai) and prod (<sub>.benmore.ai) - each with its own data, git history, and secrets.
| Command | What it does |
|---|---|
benmore promote <app> | Ship code dev → prod (never data/secrets); prod data migrated with a pre-migrate backup. Refuses destructive schema changes without --force; --dry-run previews. |
benmore seed-dev <app> | Give an existing prod app a dev instance (code, fresh data). Prod untouched. |
benmore refresh-dev <app> | Pull prod → dev (files / data / both), with schema reconciliation. |
benmore use dev|prod | Pin which environment every command targets; --env overrides per call. |
Secrets and env vars are per-environment by default (dev holds test keys, prod the live ones); collaborators, billing, and custom domains stay per logical app. Custom domains always map to prod.
Deploy & hosting
- Deploy -
benmore deploy(or the MCP tool) ships your app; auto-SSL subdomain, daily backups, per-app SQLite isolation. Schema migrations run automatically with a pre-migrate backup; pushes auto-commit to per-app git. - Custom domains -
benmore domain <app> yourdomain.com(wildcard SSL provisioned automatically). - Collaborators -
benmore collaborators <app> add user@x. - Recovery -
benmore git log/show/revert,benmore integrity-check,benmore restore.
Security
Security controls are enforced by the runtime, not left to app code:
- Parameterized SQL everywhere; field names validated against the schema.
- Auto-escaped output; mass-assignment protection on protected fields.
- CSRF (HMAC, auto-injected); HttpOnly+SameSite+Secure sessions; hashed session IDs.
- SSRF protection on every outbound URL (webhooks, flows, fetch) at registration and delivery.
- Constant-time comparison for all security-sensitive checks; brute-force lockout.
- Per-row owner/group/role scoping through one canonical access check; admin bypass is explicit and act-as-aware.
- Field-level AES-GCM encryption; signed URLs; rate limiting (per-user / per-IP); circuit breakers on outbound calls.
CLI reference
# setup
benmore bootstrap # workspace + skill + login + sync apps
benmore login / logout / whoami
benmore sync # pull all your deployed apps into ~/Benmore/
# build & ship
benmore new myapp # scaffold
benmore serve myapp # run locally
benmore deploy # go live on benmore.ai
benmore push # ship a single file
benmore pull [dir] # pull deployed source
# inspect
benmore apps # list deployed apps
benmore logs / tail
benmore sql "SELECT …" [--write]
benmore api [--at PATH] [--call BODY]
benmore probe [--as EMAIL]
benmore describe
benmore status / diff # dev↔prod drift (files, schema, env/payment keys)
benmore doctor # release-readiness PASS/WARN report
# environments
benmore use dev|prod
benmore promote / seed-dev / refresh-dev
# ops
benmore env set K=V / unset K
benmore domain
benmore collaborators [add|remove user@x]
benmore git log|show|revert
benmore disable / enable
MCP reference
The hosted MCP server at https://benmore.ai/mcp exposes the same surface to AI clients. Key tools:
help / api(at:<topic>) - discover and execute any feature (the canonical, always-current reference for every primitive above).
create_app, write_file / edit_file (auto-commit), read_file, search_app.
describe, get_schema, sql / query_db, probe_route, get_logs / get_client_errors.
promote, seed_dev, refresh_dev, set_env, set_custom_domain, git_log / git_revert.
Once connected, just describe what you want - "build me a CRM with contacts, deals, and a Kanban board" - and the agent uses these tools to scaffold, edit, and deploy. api(at:<topic>) is the source of truth for every feature's exact request/response shape.
Questions? [email protected] · Create a free account →