PricingPrivacyTermsSign in

Connect your agent › The agent's guide

apphaven client SDK and REST API

Every app serves a small SDK. The frontend talks to its own origin, so there's no URL to configure and no CORS.

<!-- static site -->
<script type="module">
  import { createClient, filter } from "/_apphaven/sdk.js";
  const app = createClient();
</script>
// Vite project (pre-installed during the build; add "@apphaven/client": "*" to dependencies)
import { createClient, filter } from "@apphaven/client";
const app = createClient();

Auth

const user = await app.auth.signUp({ email, password, name });   // password: 8+ characters; no session yet (below)
const user = await app.auth.signIn({ email, password });
await app.auth.signOut();
const user = await app.auth.getUser();      // { id, email, name, role, image } or null
const stop = app.auth.onChange((user) => render(user));   // now + on every sign in/out

Every password sign-up confirms its address first: signUp creates the account and emails a confirmation link, but returns no session (getUser() stays null), so show "check your email, then sign in". signIn answers 403 until the link is opened (each attempt before that re-sends the link). Opening it confirms the address and normally signs the person in. In preview, opening it on a device without Apphaven access confirms the address and asks them to return to the test version and sign in. A duplicate address gets the same answer as a new one, not an error, so don't look for a 422.

Errors throw ApphavenError with .status, .code and a readable .message (e.g. wrong password → 401, address not confirmed yet → 403). Show .message to the user.

Email flows (links are emailed by apphaven; nothing to configure):

await app.auth.requestPasswordReset({ email });   // emails a link; always resolves
// The link opens /_apphaven/reset-password?token=… (a plain page every app has). To use your
// own page: requestPasswordReset({ email, redirectTo: "/reset" }) and on that page:
await app.auth.resetPassword({ newPassword });     // token read from ?token= (or pass { token })

await app.auth.sendMagicLink({ email, name });     // auth.magic_link: true; opening the link signs in
                                                   // and returns to the current page (or callbackURL)
await app.auth.sendVerificationEmail({ email });   // re-sends the confirmation link

With magic_link, a sign-in form can be just an email field; the link confirms the address by being opened. Say "check your email" after these calls. In preview, only the owner's Apphaven account email address is eligible for delivery. Other recipients' mail is captured for inspection: use read_emails, then read the matching message by id to get its link. Check the actual status even for the owner; failed, limited and suppressed need attention, and sent does not prove the message reached an inbox.

Testing sign-in in preview

The tools open the private preview automatically. That access is separate from being signed in inside the app. as: "alice" or as: "bob" signs in an already-confirmed test user; use those accounts for authenticated screens and access rules. They do not test ordinary sign-up, the confirmation email or the app's sign-in form.

Test the ordinary flow separately after deploying, leaving as out throughout:

  1. Use view_page with steps to submit the app's real sign-up form. Use a fresh synthetic address such as verification-1@example.invalid and a test-only password, not the owner's credentials. Choose a new address for each run: a duplicate sign-up gives a generic answer. Do not use @apphaven.test for this check; those addresses are automatically confirmed. Check that the page asks the person to confirm their address and does not treat them as signed in yet.
  2. Check the unconfirmed account cannot sign in. For the exact API response, use test_api_write with method: "POST", path: "/api/auth/sign-in/email" and body: { "email": "verification-1@example.invalid", "password": "<your test password>" }. Inspect the error code/message as well as the status. This attempt can send another confirmation email.
  3. Call read_emails for preview, find the latest confirmation message for that exact address, then call it with that message's id. captured is expected for this non-owner address. Open the confirmation link using view_page, passing its app-local path and complete query string as path. Keep tokens out of app code and chat messages. An expired link needs a fresh confirmation email, not a change to the auth backend.
  4. In a new view_page call, use steps to fill and submit the app's real sign-in form with that account, then exercise an authenticated action and check the resulting screen in the same call. Check the phone layout too. A screenshot taken with as would bypass the sign-in form and would not establish that this flow works.

Each view_page call starts a fresh browser context; ordinary sign-in cookies do not carry over to the next call. test_api / test_api_write also do not retain ordinary sign-in cookies between calls. Address confirmation persists on the account, so step 4 can sign in again; a later signed-out test_api call is not a check of that browser session. The view_page report's signed-out/test-user label describes the starting identity; inspect the steps and resulting UI to check a sign-in performed through the form.

Test password reset using the same mail inspection process, then sign in with the new test password through the real form. If magic links are enabled, open the captured sign-in link and check the authenticated UI within that view_page call. Its session will not survive into a later call. The owner walkthrough and private-preview link rules are in the overview.

Troubleshooting sign-in

Use the endpoint and response body to distinguish these cases; a status alone is insufficient. test_api / test_api_write show the response body, while view_page reports failed requests and visible page text. Keep the UI's error message useful instead of replacing every failure with "Sign-in failed".

Evidence Next step
401 preview_locked The owner must sign in to Apphaven to open the private preview. Tool calls get preview access automatically; if a tool still gets this error, report it rather than changing the app's auth provider.
An auth response says the email or password is invalid Check the app, environment and account. Apphaven accounts, preview app accounts and live app accounts have separate credentials; resetting preview removes copied live passwords. Use the app's password-reset flow when needed.
An auth response says the email is not verified/confirmed (403) Find that address's confirmation email with read_emails, open the link, then retry sign-in. Check the message and link before resending.
403 forbidden from a model or operation Check the app user's role and the relevant access rule. This is not evidence that their email is unconfirmed.
Preview mail is captured Expected for non-owner addresses. Inspect the message and follow its link with the tools; retrying will not make it arrive in that recipient's inbox.
Mail is failed, limited or suppressed Read the mail log's error or limit/suppression detail and explain the specific problem. Do not assume the app needs an external email or auth provider.

App auth supports email/password and optional magic links. Google/Apple sign-in on an app is not currently supported; Google sign-in to the Apphaven platform does not enable it for apps. Explain the limitation and offer a supported flow. See the overview before proposing an external provider or changing where the app's accounts and data live.

Data

app.db.<Model> for every model:

const { items, has_more } = await app.db.Recipe.list({
  filter: "published == true",      // same language as rules; fields of this model only
  sort: "-created_at,title",        // '-' = descending; default -created_at
  limit: 20, offset: 0,             // default 50, max 200
  expand: ["author", "category"],   // include linked records under item.expand
});
const r = await app.db.Recipe.get(id, { expand: ["author"] });
const r = await app.db.Recipe.create({ title: "Soup", servings: 4 });
const r = await app.db.Recipe.update(id, { title: "Better soup" });   // partial update
await app.db.Recipe.delete(id);

Counts and totals (computed by the database, over the records the user may list):

// votes per book, most votes first, with the book record attached
const { items } = await app.db.Vote.aggregate({ group_by: ["book"], sort: "-count", expand: ["book"] });
// items: [{ book: "id…", count: 7, expand: { book: { title, … } } }, …]
const { items: [totals] } = await app.db.Run.aggregate({ sum: ["km"], avg: ["minutes"], filter: "date >= '2026-10-01'" });
// totals: { count: 42, sum_km: 318.5, avg_minutes: 37.2 }

Use this instead of loading every record to count them. Options: group_by, count (default true), sum, avg (number fields), min, max, filter, sort (by an output name, e.g. -count, -sum_km), limit (default 100, max 1000), expand (group fields).

  • Records come back as { id, created_at, updated_at, ...fields }.
  • expand on a user field gives { id, name, image } (never the email). On a ref field it gives the linked record if the reader may get it, else null.
  • Use filter (tagged template) whenever values come from user input, so quotes are escaped: app.db.Recipe.list({ filter: filter`contains(title, ${search})` })
  • Filters can use $user.id and $now, e.g. "owner == $user.id". Access rules always apply on top of filters; you don't need to repeat them.
  • Signed-out visitors can submit at most 20 writes a minute (contact forms, orders); more get 429 rate_limited — show the message.
  • Errors: 400 validation_failed (.details maps field → problem), 401 unauthorized (sign in), 403 forbidden, 404 not_found (also when a rule hides the record), 409 conflict (unique value taken).

Operations

await app.ops.publishRecipe({ id });                      // params at the top level
await app.ops.quickNote({ data: { text: "hi" } });        // create/update input under `data`
const { items } = await app.ops.myDrafts({ limit: 20 });

Files

File values in create, update and an operation's data are uploaded and checked first, then saved by id: app.db.Profile.update(id, { photo: input.files[0] }). For progress, upload first with app.files.upload(file, { model, field, onProgress }). Reads return each file with url and thumb_url. Signed out, files go through a form (a create operation). All of it, with examples: get_docs "files".

REST API (what the SDK calls)

method path body / query
GET /api describes models, fields and operations
GET /api/me { user }
POST /api/auth/sign-up/email { email, password, name }
POST /api/auth/sign-in/email { email, password }
POST /api/auth/sign-out {}
GET /api/models/:Model ?filter=&sort=&limit=&offset=&expand=a,b
GET /api/models/:Model/:id ?expand=
POST /api/models/:Model record JSON
PATCH /api/models/:Model/:id partial record JSON
DELETE /api/models/:Model/:id
GET /api/models/:Model/_aggregate ?group_by=a,b&sum=x&avg=y&min=&max=&count=false&filter=&sort=-count&limit=&expand=
POST /api/ops/:name { ...params, data: {...} } (GET with query params also works for reads)

Send Content-Type: application/json. Sessions are cookies, so use credentials: "include" if you call fetch yourself. Errors are { "error": { "code", "message", "details"? } }.

HomeDocsPricingPrivacyTermsSupportReport content A product of Efficacious, Inc.