Apps & Projects

An app is a tenant inside a single Anvil server: it owns a schema (app_<slug>), a membership list that links back to the global auth.users, and its own settings and email templates. Users stay global - one account can belong to many apps with a different privilege in each. Everything below is served from the same server, the same data directory, and the same auth system you already have.

Concepts

TermMeaning
AppA tenant with an app_id (UUID), an immutable slug, a display name, and an enabled flag. Stored in system.apps, mirrored as :App nodes.
App schemaapp_<slug>. Labels are bound to it explicitly; collections are named app_<slug>.<name>.
MemberA row in auth.app_members linking a user to an app with a privilege. Mirrored as (:User)-[:MEMBER_OF {privilege}]->(:App).
Privilegereader < editor < admin - the same names as the server roles. reader reads, editor writes and manages labels, admin manages members, settings and email templates.
Server adminUsers with the server-wide admin role create and delete apps and implicitly hold admin privilege in every app.

Quickstart

# 1. Create an app (server admin). The creator becomes an app admin.
curl -X POST http://localhost:7474/apps \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -d '{"slug": "crm", "name": "Customer CRM"}'
# => { "id": "3f2c...", "slug": "crm", "name": "Customer CRM", "enabled": true, "privilege": "admin" }

# 2. Add a member by username (or user id)
curl -X PUT http://localhost:7474/apps/crm/members/alice \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -d '{"privilege": "editor"}'

# 3. Bind the labels the app will use to its schema
curl -X PUT http://localhost:7474/apps/crm/labels/Lead \
  -H "Authorization: Bearer $ADMIN_JWT"

# 4. Work inside the app - either scope the request with a header ...
curl http://localhost:7474/db/query \
  -H "Authorization: Bearer $ALICE_JWT" \
  -H "X-Anvil-App: crm" \
  -d '{"query": "CREATE (:Lead {name: \"Acme\"})"}'

# ... or name the schema explicitly
curl http://localhost:7474/db/query \
  -H "Authorization: Bearer $ALICE_JWT" \
  -d '{"query": "MATCH (l:Lead) RETURN l.name", "database": "app_crm"}'

Request Scoping

Send X-Anvil-App: <id or slug> on any authenticated request. The middleware resolves the app, checks membership, and attaches app_id, app_slug, app_schema and app_privilege to the session - usable from RLS predicates as session('app_privilege'). An unknown app is a 404; a non-member is a 403. Cypher requests without an explicit database then run in the app's schema. Server admins pass membership checks with implicit admin privilege.

Schema Isolation

RuleBehaviour
Labels are bound explicitlyPUT /apps/{id}/labels/{label} (editor+). Creating a node with an unbound label inside an app schema fails with the usual “reserved by the 'public' schema” error - nothing is claimed by accident. Bindings persist in system.app_labels.
VisibilityAn app scope returns only data associated with that app: its own nodes plus the :User nodes of its members - not every user on the server, and not the server-wide :Role nodes (use the auth scope for those). This holds for server admins and on every graph read: MATCH, traversals, graph-document joins and full-text search. public / auth / system scopes never see app nodes.
Membership gates accessAny app_* schema requires membership: readers may read, editor+ may run mutating statements; otherwise 403.
Cross-schema edgesA relationship must have both endpoints in the same schema, except that anything may link to auth nodes - e.g. (:Order)-[:PLACED_BY]->(:User). From inside an app that means a member's :User: a non-member is not visible, so it cannot be matched to link to. Applies to CREATE, MERGE, MATCH…CREATE and MATCH…SET…CREATE.
Collectionsapp_<slug>.* collections need editor+ to create or write (Cypher and /docs), admin to drop. For /docs writes that editor is the caller's privilege in the app, not their server role - an OTP or OAuth sign-up (server reader) can be made an editor of an app's documents; schema DDL and Cypher writes still need the server role. Row-level security applies on top, and app admins bypass it inside their own app.
DELETE /apps/{id}Cascades: DETACH-deletes every node bound to the schema, drops app_<slug>.* collections and their sync rules, clears bindings and memberships, removes the :App node. (Cypher DROP APP requires CASCADE for a non-empty schema.)

Note: the bare UPSERT DOCUMENT IN <c> <key> {…} and MATCH DOCUMENT forms parse the collection as a plain identifier, so a dotted app_crm.leads can only be addressed via the WHERE forms, DELETE DOCUMENT, or the /docs REST API.

App-Scoped Settings

A subset of runtime settings can be overridden per app. Anything not overridden inherits the server-wide value; app overrides never change the global config. Keys are stored as app.<app_id>.<key> in system.settings; a slug in the key is canonicalised to the app id.

# REST (app admin): set, list, reset
curl -X PUT http://localhost:7474/apps/crm/settings \
  -H "Authorization: Bearer $JWT" \
  -d '{"auth.email.smtp_from": "CRM <noreply@crm.example.com>", "server.base_url": "https://crm.example.com"}'
curl http://localhost:7474/apps/crm/settings -H "Authorization: Bearer $JWT"
# => [{ "key": "auth.email.smtp_from", "value": "CRM <noreply@crm.example.com>", "source": "app" },
#     { "key": "auth.email.smtp_host", "value": "smtp.resend.com", "source": "global" }, ...]
curl -X DELETE http://localhost:7474/apps/crm/settings/server.base_url -H "Authorization: Bearer $JWT"

# Cypher (server admin)
SET SETTING 'app.crm.auth.email.otp_ttl_secs' = '120'
Scopable keys
auth.require_email_verification, auth.allow_otp_registration
auth.email.smtp_host, smtp_port, smtp_user, smtp_pass, smtp_from, smtp_starttls, webhook_url
auth.email.otp_ttl_secs, otp_max_attempts, verification_ttl_secs, template_dir
server.base_url (used for verification / reset links)

Custom Email Templates

Each app can override the verification, otp and password_reset emails instead of using the generic Anvil ones. Any subset of subject / html / text can be overridden; the rest falls back to the server template. Stored in system.app_email_templates.

curl -X PUT http://localhost:7474/apps/crm/email-templates/otp \
  -H "Authorization: Bearer $JWT" \
  -d '{"subject": "{{app_name}}: your code {{otp_code}}"}'

# Render with sample values to check the result
curl -X POST http://localhost:7474/apps/crm/email-templates/otp/preview \
  -H "Authorization: Bearer $JWT" -d '{}'
# => { "subject": "Customer CRM: your code 123456", "html": "...", "text": "..." }

# Revert to the server default
curl -X DELETE http://localhost:7474/apps/crm/email-templates/otp -H "Authorization: Bearer $JWT"
TemplateVariables
verification{{verification_link}}, {{verification_code}}, {{token}}
otp{{otp_code}}, {{ttl_minutes}}
password_reset{{reset_link}}, {{token}}
all{{app_name}}, {{app_slug}}, {{app_id}}

Sending On Behalf of an App

/auth/register, /auth/resend-verification and /auth/otp/request accept an optional app field (id or slug). The email, its TTLs and the link's base URL then come from that app's settings and templates; an unknown app returns 404. A login code also remembers its app: that app's auth.allow_otp_registration decides whether verifying the code may register a new user.

curl -X POST http://localhost:7474/auth/otp/request \
  -d '{"email": "alice@example.com", "app": "crm"}'

Cypher DDL

Everything in the quickstart can also be done from the Cypher editor. Slugs, names and usernames may be quoted or bare; labels may be written with or without the leading colon.

CREATE APP 'crm' NAME 'Customer CRM'      -- server admin; NAME defaults to the slug
SHOW APPS                                 -- apps you can access (admins: all)
APP 'crm' ADD MEMBER 'alice' PRIVILEGE editor   -- PRIVILEGE defaults to reader
APP 'crm' REMOVE MEMBER 'alice'
APP 'crm' BIND LABEL :Lead                -- editor+
APP 'crm' UNBIND LABEL :Lead              -- app admin
SHOW APP MEMBERS 'crm'
SHOW APP LABELS 'crm'
DROP APP 'crm'                            -- refused while the schema holds nodes, labels or collections
DROP APP 'crm' CASCADE                    -- detach-deletes the schema contents first

Querying Membership

Because apps and memberships are mirrored into the graph, they are queryable with plain Cypher from the auth or system schema:

MATCH (u:User)-[m:MEMBER_OF]->(a:App {slug: 'crm'})
RETURN u.username, m.privilege
ORDER BY m.privilege

Backup

GET /apps/{id}/export (app admin) returns a self-contained JSON backup of everything the app owns: metadata, members, label bindings, settings overrides, email templates, collections with their documents, the app-schema subgraph (relationships to auth nodes are annotated with the external endpoint's identity), sync rules, RLS policies and triggers. The Hammer Apps page has a one-click Download backup (.json) button.

POST /apps/import (server admin) restores a backup as a new app - pass ?slug=… to import under a different slug. Collection names and settings keys are rewritten to the new schema/app id; members are matched against auth.users by id then username; relationships to auth nodes are re-linked by identity (id / username / …). Anything that can't be matched is skipped and counted in the returned summary. Hammer's Apps page has an Import button that takes the backup file.

Hammer

The Apps page in Hammer manages all of the above - create apps, members, label bindings, settings overrides and email templates with a live preview. Every app you can access also appears in the sidebar's schema dropdown as app_<slug>, so the Cypher, Documents and Schema pages can work inside it directly.