Docs

Reviewed September 24, 2026 Β· Runtime/platform 2.7.241 Β· Cloud CLI 0.1.33

Benmore documentation

Benmore turns a data model, a little YAML config, and your frontend into a full-stack web app you can host on Benmore or your own server - 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 an external MCP client such as Claude, ChatGPT, Cursor, or Codex, from a terminal coding agent using the CLI, or by editing files yourself. Benmore does not host a chat panel. On the hosted platform, apps run on <your-app>.benmore.ai with automatic HTTPS, production database backups, and per-app data isolation.

What you get, automatically

A typed API for every table
CRUD, search, batch, pagination, and a generated TypeScript SDK - no endpoints to hand-write.
Auth & access control
Email/password + Google, sessions, MFA, multi-role RBAC, and per-row scoping out of the box.
Real-time
SSE + WebSocket rooms; changes broadcast to the right clients with one line of SDK.
Server logic without a server
Declarative hooks, flows, workflows, and cron - plus sandboxed JS when math doesn't fit SQL.

Getting started

Choose hosted deployment or self-host the open-source framework. The hosted options below start with a free account; self-hosting does not require one. 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.

MCP server URL
https://benmore.ai/mcp

2 Β· Claude Code & Codex (the CLI)

Both harnesses share the app's AGENTS.md; CLAUDE.md imports it. benmore skill install --agent codex installs the shared skill and references under ~/.agents/skills/benmore-cli; use --agent all for both. In Codex, invoke $benmore-cli. Read benmore docs harness for identity, environment selection, verification, and recovery boundaries.

Codex pushes edits explicitly. Claude edit hooks apply only when installed and when the edit tool matches; shell edits need an explicit push. Finish by verifying the changed behavior and benmore sync-status --all. The agent targets require cloud CLI 0.1.30 or later; older binaries may lack --agent.

Install the CLI and your terminal coding agent builds, runs, and deploys Benmore apps from the command line.

macOS
brew install benmore-studio/benmore/benmore-cli
benmore bootstrap        # set up ~/Benmore, install the skill, sign in, sync your apps

On Linux or CI, install with curl -fsSL https://benmore.ai/install-cli.sh | sh. benmore.ai requires the emailed sign-in code, so sign up and sign in through the browser (benmore login); password-only CLI sign-in and CLI signup are refused. For a browser-free runner, pass --token or set BENMORE_TOKEN to a token from an earlier browser sign-in.

benmore bootstrap creates your ~/Benmore workspace, installs the shared Codex and Claude Code skills, signs you in, and pulls any apps you've already deployed into ~/Benmore/<app>. On a new machine, benmore sync restores the whole workspace.

Reach a live page
benmore login
cd ~/Benmore
benmore new crm          # creates ./crm and prints its resolved directory
cd ./crm
benmore deploy
benmore open .           # open the printed development URL

A reachable page at the printed HTTPS URL is success. The hosted workflow has no Benmore localhost server.

3 Β· Deploy an existing frontend

Vite, Next.js static exports, and Create React App are supported without adding a Node build to the Benmore app. Build in the original project, copy only dist/, out/, or build/ contents into static/, then deploy. Keep node_modules and package manifests out. See the existing frontend guide for framework config, asset paths, SPA fallback, and asset-404 behavior.

App anatomy

An app is a directory. Here's everything it can contain - most files are optional.

myapp/
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          # self-hosted secrets; hosted apps use `benmore env APP set K=V`
└── data.db           # SQLite - created automatically

The build loop

  1. Describe or scaffold. Ask your AI to build something, or run benmore new myapp.
  2. Edit. Change schema.prisma, app.yaml, or static/*.tsx. TSX recompiles on the next request (~10 ms).
  3. Deploy. benmore deploy creates or updates the hosted development instance. Open the HTTPS URL it returns. Schema changes can apply migrations; inspect errors and backups.
  4. Iterate and verify. Push reviewed files, check the deployed app, exercise the changed browser journey, and require clean benmore sync-status. Git history restores source; database recovery is separate.

Data model - schema.prisma

A supported Prisma-style subset, parsed by Benmore and compiled to SQLite; it does not run a Prisma client or configure a PostgreSQL connection. Every model becomes a table with a full CRUD API at /api/<table>.

schema.prisma
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=...
}

Auto-CRUD API

For every table, with no code:

MethodPathWhat 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>/batchBulk operations
POST /api/<table>/ingestNDJSON 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:

GET /api/notes?…
?orderBy=created_at:desc      # sort
&per_page=50&page=2           # offset pagination
&cursor=0&limit=50            # keyset pagination (stable under inserts)
&where[status]=active         # equality filter
&where[amount__gt]=100        # operators: __gt __gte __lt __lte __ne __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 relation

Unpaged lists return a JSON array (50 rows by default). With page, the response is {data, total, page, per_page}; with cursor it is {data, next_cursor, limit} (the cursor is the last row's id; start at 0, stop when next_cursor is null); ?count=true returns {count}. Inspect the actual envelope before iterating. Encrypted-column filtering has separate blind-index and authorization rules; read api(at:"encryption") and api(at:"query") before filtering or aggregating sensitive data.

Versioning, soft-delete & concurrency

FeatureHow
Soft deleteAdd a deleted_at column β†’ DELETE flips it; reads hide deleted rows; restore via the SDK.
Optimistic concurrencySend _expected_updated_at on PATCH; a stale value returns 409 Conflict instead of silently overwriting.
Content versioningGET /api/<t>/{id}/versions, POST …/revert/{version}, and ?as_of=<iso> point-in-time reads.
Record lockingPessimistic locks: POST/DELETE/GET /api/<t>/{id}/lock with automatic expiry.
IdempotencySend X-Idempotency-Key on POST to make double-submits safe.
Audit trailEvery mutation is logged with actor + before/after values; query at GET /api/_audit (admin).
Retentionretention.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.

encrypted.yaml
tables:                          # required top-level key
  paychecks:
    fields: [wages_cents, ssn]   # works on TEXT, INTEGER, and REAL columns
    blind_index: [ssn]           # deterministic HMAC sibling β†’ equality search
    unmask_roles: [admin, payroll]

Aggregates, computed & custom fields

app.yaml
aggregates:
  total_revenue:
    sql: "SELECT SUM(amount) AS v FROM orders WHERE status='paid'"
    refresh: "5m"

Configuration - app.yaml

Start with app.yaml for core settings; flows, hooks, cron and workflows have their own files.

app.yaml
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

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: org_members         # membership table
  key: org_id                # tenant column on tenant-scoped tables
  user_field: email          # how a membership row names the user

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,profile_fields}, features.{admin,sse,ws,ws_anonymous}, auto_memberships, ws_rooms, frontend.stack (the scaffold flavor; html by default).

OAuth uses per-app GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET values or managed hosted login. Custom provider metadata belongs under top-level oauth:, never inside auth:; sign-in starts at /auth/google. Read api(at:"oauth") for the selected deployment.

Malformed core configuration is rejected before startup/reload mutations. The running handler is retained on rejected reload; this does not undo committed migrations or external effects. Use api(at:"recovery") for exact coverage.

Auth & sessions

Accounts marked for password replacement must complete that flow before normal authentication. API tokens cannot mint further tokens, and explicit token scopes are intersected with current role grants. Read api(at:"auth") for current session, token, and recovery contracts.

Access control & RBAC

Authorization is enforced server-side. Set per-table modes in app.yaml's access: block:

ModeWho can
anonAnyone, including signed-out visitors (a public feed, a contact form)
everyoneAny signed-in user
selfOnly the row's owner (user_id match)
groupMembers of the caller's current tenant (see groups:)
adminAdmins only
role:a,bAny of the listed roles
owner_or_role:a,bTiered: 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
offEndpoint disabled (404, even for admins)

A table with no rule defaults to group when it has the tenant key, otherwise self when it has user_id, otherwise admin. Read api(at:"access") for the exact rules.

Multi-tenant groups

The groups: block isolates each customer/team/org's data. table names the membership table, user_field the column that identifies the member, and key the tenant column (e.g. org_id) on tenant-scoped tables. The framework filters every query by the caller's effective group - including admins acting on behalf of a tenant. Membership rows count only while live (soft-deleted rows never do; groups.active_field can require an active flag). groups.bootstrap adds POST /api/_groups/create for founding a tenant, and auto_memberships can auto-join every active user to rows of a parent table. See api(at:"scoping").

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:

Every import must resolve inside static/: the bundle is public, so an import that reaches outside it (for example '../env.yaml') is refused. A build error clears once the file that caused it is fixed, including an imported file.

Want React/Preact? import it from a CDN in your .tsx - the framework treats static/ as opaque. For SEO-critical marketing pages, use server-rendered pages; api(at:"frontend") covers the options.

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.

static/app.tsx
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' });
MethodNotes
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).

hooks.yaml
before_insert:
  orders:
    - sql: "SELECT CASE WHEN {{amount}} <= 0 THEN RAISE(ABORT, 'Amount must be positive') END"

on_update:
  orders:
    - notify: "{{user_id}}"
      notify_title: "Order {{id}} shipped"
      when: "status = 'shipped' AND old_status IS NOT 'shipped'"
    - sql: "UPDATE inventory SET qty = qty - {{qty}} WHERE sku = {{sku}}"

Hook templates see session context ({{user_id}}, {{user_email}}, {{user_role}}, {{user_group_id}}, the caller's current tenant) and, in on_update, the previous values ({{old_status}}, …). Before-hooks also get {{_submitted}}, the columns the request is writing. when: compares one column with a literal (status = 'shipped'). Entry keys are sql, webhook (with body), email (to, subject, template), notify (with notify_title, notify_body, notify_link, notify_type), sms, ws and when; any other key is rejected when you push.

when: is a SQL condition over the changed row, evaluated read-only: the row's columns, old_<column> on updates, and user_id, user_email, user_role and user_group_id, so status IN ('approved','rejected'), assignee_id IS NOT NULL and subqueries all work. Workflow transitions take the same when:. Name columns rather than {{templates}}, and quote text values. A when: that can't be evaluated never matches, so a before_* hook with one refuses the write; benmore check reports it first.

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.

flows/notes-summary.yaml
# flows/notes-summary.yaml
on:
  request:
    method: GET
    path: /api/notes-summary
    auth: required
jobs:
  summary:
    steps:
      - id: count
        run: sql
        query: SELECT COUNT(*) AS total FROM notes WHERE user_id = :user_id
      - run: respond
        with:
          status: 200
          body: { total: "${{ steps.count.outputs.total }}" }

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 default timeout; the module and its imports must live inside static/; the result must be JSON). 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

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. A transition passes the same gates as a PATCH (update access, row scope, record locks, hooks, audit) and answers 409 state changed if the row changed meanwhile. A role: guard reads the caller's role in their current tenant.

workflows.yaml
orders:
  table: orders
  field: status
  initial: draft
  transitions:
    draft:
      submitted: {}
    submitted:
      approved: { role: manager }
    approved:
      shipped: { role: admin }

Scheduled work

cron.yaml accepts a jobs: list or named top-level jobs. Reference a flow to reuse its steps; scheduled execution has no caller session and bypasses the flow's HTTP wrapper. Retries require idempotent effects. See api(at:"cron").

cron.yaml
jobs:
  - id: refresh_catalog
    schedule: "every 15m"
    flow: refresh_catalog
    max_attempts: 1

Real-time - SSE & WebSocket

WebRTC & livestream

Files, uploads & PDF

Notifications & webhooks

Edges

An edge is a single self-contained HTML document hosted on its own isolated origin, edge-<slug>.benmoreusercontent.com, a separate domain from benmore.ai - a shareable mini-tool, calculator, form, or demo. Older edge-<slug>.benmore.ai links redirect there with their path and query. Use the url the edge tools return rather than building one. 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, which is anonymous on hosted apps (edge sign-in answers 501 there). Create edges with benmore edge new <app> or the MCP paste_create tool; see api(at:"edges").

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.

CommandWhat 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 by default; --data-only or --with-data includes real prod data and its encryption key). Review --dry-run before replacing dev work.
benmore use dev|prodPin 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. Platform-owned keys (BENMORE_PLATFORM_*, BACKUP_S3_* and similar) can't be set, and only the app owner can set ENCRYPTION_KEY.

Deploy & hosting

Operations & recovery

Since 2.7.226 the platform checks configuration loading, lifecycle coordination, restore ordering, environment materialization, and runtime activation. Use benmore api APP recovery --env dev and benmore api APP environments --env dev for the precise behavior of your deployment.

ActionWhat to verify
Push / reloadSource writes may queue a reload. Inspect errors and the active app, then source synchronization. Rejected core YAML retains the active handler; committed migrations and external effects are not reversed.
PromoteReview --dry-run, schema drift, environment secrets and verification evidence. Promotion moves source; publish changes testing posture separately. ship . --promote requires an interactive terminal.
Refresh devData refresh snapshots and validates prod's database/key pair before replacing dev state. It preserves the previous dev pair and verifies activation. A failure may leave source or data changed: inspect stage and recovery paths before retrying.
RestartSuccess requires a new healthy runtime receipt. Check logs and health progress when startup is slow; avoid repeatedly interrupting migrations.
Source revertRestores source history only. It does not reverse data loss, migrations, messages, or payments.
Database restoreReplaces data from a verified snapshot after stopping/draining writers. The prior database bundle is preserved; a fresh runtime must activate. Later writes are lost, and encrypted data still needs its matching key.

Covered lifecycle operations share an app slot. This coordinates platform operations, not arbitrary external writes or a distributed transaction. The agent guide connects these boundaries to the release workflow.

Self-host the framework

The MIT-licensed framework runs one app with benmore serve /path/to/app --port 8080. No platform account is required. Install the framework edition from the public releases; the cloud CLI has a different version and deployment role.

You operate HTTPS, process supervision, disk, backups and external integrations. Persist the app database, uploads, environment and encryption key. Hosted account/MCP/fleet services are not part of the public framework. Follow the complete self-hosting guide for a verified Linux installation, systemd, Caddy, backup/restore, upgrades, and moving an existing app.

Security

Security controls are enforced by the runtime, not left to app code:

CLI reference

common commands
# setup (hosted cloud CLI)
benmore version --json
benmore docs harness
benmore skill install --agent all
benmore bootstrap            # workspace + skill + login + sync apps
benmore login / logout / whoami
benmore sync                 # pull all your deployed apps into ~/Benmore/<app>

# build & ship
benmore new myapp            # scaffold
benmore deploy               # create/sync the deployed development app
benmore push <file>          # ship a single file to development
benmore pull <app> [dir]     # pull deployed source
benmore verify [dir]         # tests + journeys + durable evidence
benmore verify --release     # add release-safety analysis
benmore ship --promote       # verify, then enter confirmed promotion

# inspect
benmore apps                 # list deployed apps
benmore logs <app> / tail <app>
benmore sql <app> "SELECT …" [--write]
benmore api <app> [TOPIC_OR_ROUTE] [--call BODY]
benmore probe <app> <method> <path> [--as EMAIL]
benmore describe <app> <table|flows|hooks|webhooks>
benmore status <app> / diff <app>   # dev↔prod drift (files, schema, env/payment keys)
benmore doctor <app>                # release-readiness PASS/WARN report

# environments
benmore use dev|prod
benmore promote <app> / seed-dev <app> / refresh-dev <app>

# ops
benmore env <app> set K=V / unset K
benmore domain <app> <domain>
benmore collaborators <app> [add|remove user@x]
benmore git log|show|revert <app>
benmore edge new|list|open|delete <app>
benmore disable <app> / enable <app>

MCP reference

The hosted MCP server at https://benmore.ai/mcp exposes the same surface to AI clients. Key tools:

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 β†’