PricingPrivacyTermsSign in

Connect your agent › The agent's guide

apphaven config reference

Backend config lives in apphaven/*.yaml (any number of files; they're merged). Top-level keys: app, auth, models, operations, frontend. Everything is optional — an app with no config is a plain static site.

app

app:
  name: Recipe Box
  description: Share recipes with friends

auth

Sign-in is on by default (email + password), with Apphaven's own accounts and database. No external auth provider setup is needed. Optional magic links use the same app accounts. Google/Apple sign-in for apps is not currently supported by this config; Google sign-in to the Apphaven platform is separate. Offer a supported sign-in method and explain the gap before proposing an external provider (see the overview).

auth:
  enabled: true          # false = no sign-in at all
  email_password: true
  magic_link: false      # true = "email me a sign-in link" (no password needed)
  signup: open           # open | closed (closed = only existing users can sign in)
  roles: [user, admin]   # default [user, admin]
  default_role: user     # role given to new sign-ups

Roles are assigned with the manage_users tool, action: set_role (e.g. make the app owner an admin). Rules read the role as $user.role. The owner first signs up inside the app; their Apphaven account does not automatically create an app account or grant an app role.

Every password sign-up confirms its address: the person gets a confirmation email and can't sign in until the link is opened (see the sdk docs for what signUp returns and what to show). There is no switch; an old verify_email: line is ignored with a warning, so remove it. Test users (alice@apphaven.test…) are confirmed on creation in the preview; the live app refuses @apphaven.test addresses. Test ordinary sign-up and confirmation separately without as, using the walkthrough in get_docs topic sdk, "Testing sign-in in preview".

Sign-in mail needs no setup: password reset is built in (app.auth.requestPasswordReset), and confirmation and magic_link emails send their links automatically. Emails come from "<App name>" <<slug>@viaapphaven.com> (the test version adds +preview). From preview, only the owner's Apphaven account email address is eligible for delivery. Other recipients' mail is captured for inspection. Use read_emails to check the actual status, then pass a message's id to read it and follow its link; owner mail can also fail or be limited.

models

Each model is a table with an API at /api/models/<Model>. Names are PascalCase; field names are snake_case. Every record also has id (text), created_at and updated_at (set automatically).

models:
  Recipe:
    description: A recipe someone shared     # optional
    fields:
      title: { type: string, required: true, max_length: 120 }
      body: text
      servings: { type: integer, min: 1, default: 2 }
      cuisine: { type: enum, values: [italian, thai, mexican, other] }
      published: { type: boolean, default: false }
      category: Category                        # shorthand for { type: ref, to: Category }
      author: { type: user, default: $user.id, readonly: true, required: true }
    indexes: [[published, created_at]]          # optional extra indexes
    unique: [[author, title]]                   # optional multi-field uniqueness
    access:
      read: published == true || author == $user.id
      create: $user != null
      update: author == $user.id
      delete: author == $user.id || $user.role == 'admin'
  Category:
    fields:
      name: { type: string, required: true, unique: true }
    access:
      read: true
      write: $user.role == 'admin'

Field types

type stores notes
string short text max 2000 chars unless max_length
text long text max 200 000 chars
integer whole number min, max
number decimal number min, max
boolean true/false
datetime date + time ISO 8601 strings, e.g. 2026-03-01T09:30:00Z
date calendar date YYYY-MM-DD
json any JSON objects, arrays (e.g. a list of tags)
enum one of values values: [a, b, c]
ref link to another record to: Model; value is that record's id
user link to a signed-in account value is a user id
image a photo someone sent the app many: true for a list; see get_docs "files"
file a document someone sent the app types: [pdf, document, text, image], max_mb, many; see get_docs "files"

A field can be written as just its type (body: text) or a mapping with options:

  • required: true — must be present (and non-empty for text).
  • default: — a value, $now (datetime/date), or $user.id (the signed-in user).
  • readonly: true — clients can't set or change it (it's set by its default, or by operations).
  • unique: true, index: true
  • min, max (numbers), min_length, max_length, pattern (regex), format: email | url (strings)
  • on_delete: for ref/user — cascade (delete this record too), set_null, or restrict (block the delete). Default: cascade if required, else set_null.
  • renamed_from: old_name — rename without losing data (also works on models).
  • description: — notes for humans.

Access rules

Each model lists who may do each action. No rule = nobody (not even signed-in users).

key covers
list listing/filtering records
get fetching one record by id
create, update, delete writes
read shorthand for list + get
write shorthand for create + update + delete

Specific keys win over shorthands. A rule is true, false, or a condition:

author == $user.id                       the record's author is the signed-in user
$user != null                            anyone signed in
$user.role == 'admin'                    admins
published == true || author == $user.id
status in ['open', 'pending']
team.owner == $user.id                   follow a ref field to the linked record
exists(Membership, team == $row.team && user == $user.id)   a matching record exists
contains(title, 'urgent')                case-insensitive substring; also startsWith(a, b)
due < $now
  • Bare names are fields of the record. Follow ref fields with dots (project.owner); user fields expose .name, .email, .role.
  • $user is the signed-in user ($user.id, .email, .name, .role), or null when signed out. $now is the current time. $row.x is the record being checked (useful inside exists).
  • exists(Model, condition) is true when some record of Model matches; inside it, bare names are that Model's fields.
  • Operators: == != < <= > >=, in [...], not in [...], &&/and, ||/or, !/not, parentheses. Strings use single or double quotes.
  • Anything involving a missing value is false: a signed-out user never matches author == $user.id (even when author is empty), and an empty field is neither == nor != anything. Check emptiness explicitly with field == null / field != null, and optional params with $params.q == null || contains(title, $params.q).

How rules apply:

  • list/get/update/delete only see records the rule allows (others look like they don't exist).
  • create and update are re-checked on the resulting record, so a user can't create or edit a record into a state they wouldn't be allowed to have (e.g. move a task into someone else's project).
  • Rules that don't look at the record at all ($user != null, $user.role == 'admin') are decided up front: signed-out visitors get 401 and other users 403, instead of an empty list.
  • Fields marked readonly are ignored when clients send them.

Common patterns

Private per-user data:

owner: { type: user, default: $user.id, readonly: true, required: true }
access: { read: owner == $user.id, create: $user != null, update: owner == $user.id, delete: owner == $user.id }

Public content, owner edits:

access: { read: true, create: $user != null, update: author == $user.id, delete: author == $user.id }

Anyone can submit, only admins can see (contact form, waitlist):

access: { create: true, read: $user.role == 'admin', delete: $user.role == 'admin' }

Teams / shared spaces:

Team:
  fields:
    name: { type: string, required: true }
    owner: { type: user, default: $user.id, readonly: true, required: true }
  access:
    read: owner == $user.id || exists(Member, team == $row.id && user == $user.id)
    create: $user != null
    update: owner == $user.id
    delete: owner == $user.id
Member:
  fields:
    team: { type: Team, required: true }
    user: { type: user, required: true }
  unique: [[team, user]]
  access:
    read: user == $user.id || team.owner == $user.id
    create: team.owner == $user.id
    delete: team.owner == $user.id || user == $user.id
Task:
  fields:
    team: { type: Team, required: true }
    title: { type: string, required: true }
  access:
    read: team.owner == $user.id || exists(Member, team == $row.team && user == $user.id)
    write: team.owner == $user.id || exists(Member, team == $row.team && user == $user.id)

To invite someone by email, have them sign up first, then add a Member record with their user id — or store an Invite model with email and a rule like email == $user.email. Every signed-in user has confirmed their address, so $user.email is theirs.

operations

Named endpoints for anything beyond plain CRUD: preset queries, server-set values, actions that need different permissions. Called with POST /api/ops/<name> (or app.ops.<name>(input)).

operations:
  myDrafts:
    model: Recipe
    action: list                 # list | get | count | aggregate | create | update | delete
    where: author == $user.id && published == false
    sort: [-updated_at]
    limit: 100                   # max page size (clients can pass smaller limit/offset)
  publishRecipe:
    model: Recipe
    action: update
    params: { id: string }       # types: string, integer, number, boolean, datetime, date
    where: id == $params.id && author == $user.id
    set: { published: true }     # server-set values: literals, $now, $user.id, $params.x
  quickNote:
    model: Note
    action: create
    input: [text]                # fields the client may send (as `data`)
    set: { pinned: false }
  publicRecipeCount:
    model: Recipe
    action: count
    where: published == true
    access: true                 # replaces the model's rule for this operation
  leaderboard:
    model: Run
    action: aggregate            # grouped totals
    params: { since: date }
    where: date >= $params.since
    group_by: [runner]
    sum: [km]                    # also: avg, min, max; count is on by default
    sort: [-sum_km]
    limit: 20

Marking meaningful results

An operation may declare the result that means someone got value from the app. Ask what the owner wants people to accomplish, then mark a small number of actions such as completing a booking or publishing a project. Do not mark every save, read, seed or background refresh.

operations:
  complete_task:
    model: Task
    action: update
    params:
      id: { type: string, required: true }
    where: id == $params.id && done == false
    set: { done: true }
    access: $user != null
    outcome:
      id: task_completed
      version: 1
      kind: meaningful
      when: changed

The usual model access rules still apply. outcome supports create, update and delete operations only, with at most 32 declarations per app. id is a stable 3–64 character lowercase identifier (letters, numbers, underscores) that starts with a letter, such as booking_completed; never put a person's name, address or record id in it. version is an integer from 1 to 10000. Use kind: meaningful for useful activity or kind: conversion for a completed business goal. when: changed requires at least one affected record; it counts one completed invocation, separately from the affected-row count.

Use a condition such as done == false when repeating a request must not count another completion. PostgreSQL can report an affected row even when an update writes the same value. Generic CRUD requests do not become meaningful outcomes just because they succeed. Increase the version when the result's meaning changes; each event keeps the declaration from its immutable deployment. A later email failure does not undo a committed action or prove delivery.

Apphaven observes account creation, address confirmation and completed sign-in on the server. No tracking snippet or external analytics package is needed for these outcomes. Collection may not be switched on yet: a valid declaration does not mean data already exists. Test through Apphaven's preview tools; recognized tests are kept separate from live adoption. Do not invent customer traffic to populate charts.

Sending email from an operation

A create or update operation can send an email after it succeeds, once per record it touched:

operations:
  placeOrder:
    model: Order
    action: create
    input: [item, qty, customer]
    email:
      to: [owner, customer.email]        # who gets it (see below)
      subject: "New order: {{ item }}"   # {{ placeholders }} work here too
      template: emails/order.html        # the file apphaven/emails/order.html
      reply_to: customer.email           # optional
  confirmBooking:
    model: Booking
    action: update
    params: { id: string }
    where: id == $params.id
    set: { status: confirmed }
    email:
      to: guest                          # a user field: their address
      subject: Your booking is confirmed
      body: |                            # plain text instead of a template
        Hi {{ guest.name }}, your booking for {{ date }} is confirmed.
        See it at {{ app.url }}/bookings/{{ id }}
  • email can also be a list of up to 5 such mappings, when one action should send different messages, e.g. an alert to the owner and an acknowledgement to the customer.

  • to (one or a list, at most 10): owner (the app owner's apphaven address), $user (whoever ran the operation), $params.<name> (a string param), a field of the record that holds an address (customer_email, ideally format: email), a user field (assignee), or <ref field>.<field> (customer.email, customer.account). A literal address also works.

  • template is an HTML file under apphaven/emails/. It isn't served to visitors. Write a fragment (<p>…</p>): it's placed in a frame with the app's name and a footer. A full <html> document is sent as is. This is implemented for operation notifications. Account emails (confirmation, sign-in link and password reset) use Apphaven's fixed copy and the app's name; there is no custom auth-template setting.

  • Placeholders: {{ field }} for the record's fields (refs and users expand one level: {{ customer.name }}, {{ assignee.email }}), {{ params.x }}, {{ user.name }} / {{ user.email }} (who ran it), {{ app.name }} / {{ app.url }}. Values are escaped in templates; dates read like "Oct 18, 2026, 9:30 AM UTC". validate reports a placeholder that doesn't match a field.

  • Notification emails get a "Stop these emails" link; sign-in emails don't. An address that bounces, complains or unsubscribes is skipped from then on (the owner sees these in the console's Mail page).

  • Live limits: up to 500 emails a day, but a new live app starts at 100 a day for its first 7 days of sending and 250 until day 30 (the limit grows with a clean record). An address nobody confirmed (anyone but the owner, a signed-in user, or a literal address in the config) gets at most 5 messages an hour and 20 a day from one app. Sending pauses by itself on a spam complaint or too many bounces; read_emails says so in its first line.

  • Preview: nothing is delivered except to the owner's address; read_emails shows what would have gone out, and with an id shows one rendered. Test the flow there first.

  • Owners can preview every deployed email from the console's Mail page, before sending any: phone, desktop and plain text, filled with sample details. The selected test/live deployment supplies the templates; workspace edits appear after deploy_preview (and after publishing for live). Full HTML documents keep their own layout. Keep custom layouts fluid, use inline styles and presentation tables, and let long links wrap. The gallery is for appearance; read_emails still checks actual recipient resolution and the rendered data from a real flow.

  • access (optional) replaces the model's rule for that action; without it the model's rule applies. Operations can therefore allow narrow things the model doesn't (e.g. anyone may count, only via this operation).

  • where narrows which records are touched ($params.x refers to params). Required for update/delete operations, which may change several records at once.

  • set values bypass readonly; input lists what clients may send.

  • Params can have default: and required: false.

  • Results: list → { items, has_more, offset, limit }; get/create → the record; update → { count, items }; delete → { count }; count → { count }; aggregate → { items: [{ <group fields>, count, sum_<f>, avg_<f>, min_<f>, max_<f> }] }.

  • Clients can also aggregate ad hoc with app.db.<Model>.aggregate(...) (see the sdk docs); an aggregate operation is for fixed reports or ones that need different access.

frontend

frontend:
  build: auto        # auto (npm if package.json has a build script) | none | vite
  output_dir: dist   # what to serve after the build (static sites: a folder to serve instead of the root)
  spa: true          # unknown paths serve index.html
HomeDocsPricingPrivacyTermsSupportReport content A product of Efficacious, Inc.