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
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.
https://benmore.ai/mcp- Claude.ai - Settings β Connectors β Add custom connector, paste the URL, sign in.
- Other MCP clients - use your client's current remote MCP setup and authenticate to Benmore. See the connection guide.
- Cursor - add it to
~/.cursor/mcp.jsonundermcpServers.
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.
brew install benmore-studio/benmore/benmore-cli
benmore bootstrap # set up ~/Benmore, install the skill, sign in, sync your appsOn 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.
benmore login
cd ~/Benmore
benmore new crm # creates ./crm and prints its resolved directory
cd ./crm
benmore deploy
benmore open . # open the printed development URLA 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/
βββ 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 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 deploycreates or updates the hosted development instance. Open the HTTPS URL it returns. Schema changes can apply migrations; inspect errors and backups. - 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>.
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). An encrypted column can't be listed there. - 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
&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 relationUnpaged 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
| 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.
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]- Blind index lets you still do
?ssn=β¦equality lookups on encrypted data (a separate HMAC key, rotated in lockstep). The filter answers 403 to callers who would see the column masked, and the<col>_blindcolumn is never returned. 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 - an app admin can add columns at runtime via
POST /api/_schema/<table>/fields, no migration.
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.
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.
- Sign up / sign in - email + password or Google OAuth, built in.
bm.auth.signUp/signIn/signOut/meon the client. - Sessions - HttpOnly cookies for web (over HTTPS the cookie is
__Host-benmore_session: host-only andSecure); Bearer tokens for API/native (POST /api/_auth/tokenexchanges credentials for a token; sendAuthorization: Bearer <token>). Withauth.otp, the token exchange answers 403otp_required. - MFA - TOTP with backup codes:
bm.mfa.enroll/verify/disable. TOTP secrets are encrypted at rest when the app has a durable encryption key. - Password reset & email verification - token flows at
/forgot-password,/reset-password; opt-in verify viaauth.verify_email. Changing an email sends a link to the new address; the change applies when it is opened. Grants keyed by email (memberships, shares, invites) apply only to verified addresses; unverified accounts that predate this rule verify at their next password sign-in. - Profile & sessions API -
GET/PATCH /api/_auth/profile(PATCH writes only name/avatar-style fields,auth.signup_fieldsandauth.profile_fields), list/revoke active sessions (GET/DELETE /api/_auth/sessions). Session IDs are stored only as SHA-256 hashes. - 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 |
|---|---|
anon | Anyone, including signed-out visitors (a public feed, a contact form) |
everyone | Any signed-in user |
self | Only the row's owner (user_id match) |
group | Members of the caller's current tenant (see groups:) |
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 |
off | Endpoint 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-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). Sharing needs current edit access to the row, and a share never crosses tenants. - 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. 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:
- an import map so
import bm from 'bm'resolves to the SDK; - a CSRF meta tag the SDK reads automatically;
- cache-busting
?v=<content hash>on your/static/<script>/<link>refs. A.jsversion covers every file understatic/, so editing an imported.tsx,.json,.cssor text file changes it too. The framework replaces any?v=you write; another query string turns the rewrite off for that reference.
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.
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:
- 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
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 }}" }- Step types:
sql,sql_dynamic,api(outbound HTTP, SSRF-guarded),parse,set,if,for_each,parallel,compute(server JS),email,sms,webhook,broadcast(message a WebSocket room),redirect,respond, plusserve_file,delete_upload,transcribeandpurge_current_user. There is norun: wsstep; userun: broadcast. - Live updates:
run: sqlandrun: sql_dynamicwrites emit the same{table, action}event as auto-CRUD, after commit in a transactional flow and never on rollback, sobm.live()views refresh without polling. - Transactions:
on.request.transaction: truegroups supported SQL work; external requests and files are not rolled back. Normal user-facing writes should keep using the CRUD pipeline. - 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:
on.request.rate_limit: "5/hour per ip"answers 429 before any step runs. The scope isip(default),user,emailorgroup; signed-out callers fall back to IP. - Identity:
user_id,user_email,user_roleand the tenant keys come from the session and can never be set by the request; a public flow binds them to NULL for signed-out callers. - Pipes:
{{ steps.x.outputs.json.items | length }}- inspect the canonical flow recipe for supported template expressions. - Conditions:
if:andsuccess_when:take one comparison (==!=>=<=><; numbers compare as numbers) or an expression with&&,||,!and parentheses:if: ${{ steps.call.outputs.status >= 200 && steps.call.outputs.status < 300 }}. The condition you write is parsed first; values from the request or a step are only ever compared, never read as part of the condition.
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
- 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. 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.
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").
jobs:
- id: refresh_catalog
schedule: "every 15m"
flow: refresh_catalog
max_attempts: 1Real-time - SSE & WebSocket
- SSE (
/sse/events) - the framework broadcasts a{table, action}event on every auto-CRUD mutation and every flowrun: sql/sql_dynamicwrite, 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; room messages arrive astype: "message". A flow'srun: broadcaststep sends a server message (from: "server") to a room's current members.ws_rooms:in app.yaml ties room names to a membership table. 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, only for signed-in callers unlessfeatures.ws_anonymousis set).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 on a separate cookieless domain (media.benmoreusercontent.com; olderusercontent.benmore.aiURLs keep working). Multipart request bodies are capped (51 MB by default).- Signed URLs - time-limited, HMAC-signed file access via
bm.signedUrl(path, ttl). A private file is signed only through a record that vouches for the uploader. - PDF -
GET /pdf/<page>prints a server-rendered page to PDF with sandboxed headless Chrome on the host. Client-rendered TSX content is not included. - CSV export - the
ExportButtoncomponent downloads table rows as CSV.
Notifications & webhooks
- In-app inbox - per-user notifications pushed over SSE/WS.
bm.notifications.list/markRead/onNew; create from hooks withnotify:. - Outbound webhooks - an app admin registers a URL (
POST /api/_webhooks) for create/update/delete events. Deliveries are HMAC-SHA256 signed, retried as background jobs, SSRF-guarded (private IPs blocked at registration and delivery), and stop once the subscription's owner is no longer an active admin.
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.
| 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 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|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. Platform-owned keys (BENMORE_PLATFORM_*, BACKUP_S3_* and similar) can't be set, and only the app owner can set ENCRYPTION_KEY.
Deploy & hosting
- Deploy -
benmore deploy(or the MCP tool) ships your app; auto-SSL subdomain, per-app SQLite isolation. Schema migrations run automatically with a pre-migrate backup; pushes auto-commit to per-app git. Production databases are snapshotted hourly when they change and before every deploy; dev instances are not backed up. - Custom domains -
benmore domain <app> yourdomain.com(Pro and Scale). Follow the returned DNS instructions, including any ownership TXT record they list; verify HTTPS after provisioning. Domains target production. - Collaborators -
benmore collaborators <app> add user@x. Inviting needs an owner plan that includes collaborators (Scale). - Recovery -
benmore git log/show/revert,benmore integrity-check,benmore restore.
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.
| Action | What to verify |
|---|---|
| Push / reload | Source 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. |
| Promote | Review --dry-run, schema drift, environment secrets and verification evidence. Promotion moves source; publish changes testing posture separately. ship . --promote requires an interactive terminal. |
| Refresh dev | Data 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. |
| Restart | Success requires a new healthy runtime receipt. Check logs and health progress when startup is slow; avoid repeatedly interrupting migrations. |
| Source revert | Restores source history only. It does not reverse data loss, migrations, messages, or payments. |
| Database restore | Replaces 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:
- Parameterized SQL everywhere; field names validated against the schema.
- Auto-escaped output (
{{var}}escapes for its HTML, URL or script context); mass-assignment protection on protected fields. - Secret-named columns (
*_secret,*_token,*_password,api_key-style names) are hidden from every read response. - CSRF (HMAC, auto-injected); HttpOnly+SameSite+Secure
__Host-session cookie; session IDs stored only as hashes. - Admin management endpoints (admin panel, audit log, webhooks, jobs, analytics, key rotation) need an admin signed in with an unscoped credential; an admin's scoped token gets 403.
- Browser bundles and
run: computemodules can import only files insidestatic/; pushes containing provider-shaped secrets are refused. - 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 (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:
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,tail_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 β