Serenities AI™
Guides
GuideBackend Functions

Backend Functions

Server-side JavaScript functions that run in isolated Deno sandboxes. Access your database, files, external APIs, and environment variables securely.

You can set up backend functions just by chatting

You don't have to build backend functions by hand — describe what you want and AI does it for you. Use the built-in Build chat inside Serenities, or connect Claude, ChatGPT, or any MCP client over MCP and do it from your own AI. You can still set it all up by hand — this page is the reference either way.

Write JavaScript code that runs on the server. Each function gets a ctx object with methods to interact with your app's data.

Call functions from your published app using: await functions.call('myFunction', params)

Scope: a backend function belongs to one site. For logic that spans several of your sites, or that an external service calls by URL, use an account function & webhook instead.

The ctx Object

Every backend function receives a ctx object and a params object. The ctx object provides access to all platform features:

PropertyDescription
ctx.tablesDatabase operations (respects access rules)
ctx.service.tablesDatabase operations (bypasses access rules)
ctx.filesFile operations (respects access rules)
ctx.service.filesFile operations (bypasses access rules)
ctx.fetchHTTPS requests to external APIs
ctx.envAccess environment variables
ctx.userCalling user's { id, email, name, role, roleIds, roleNames, profileData } or null. Branch on roleNames — role is only the built-in user/admin flag
ctx.emailSend transactional email via the project's sender identity
ctx.entitlementsGrant/extend/suspend/revoke paid-access entitlements — the only write path onto the entitlement ledger
ctx.realtime.publishPush an event to connected clients on a channel
ctx.agents.enqueueHand work to one of the owner's AI agents (fire-and-forget; the agent must be granted to this site)

ctx.tables — Database Operations

All 15 methods available on ctx.tables. These respect the table's access rules based on the calling user: a member or visitor who calls the function reads and changes only the rows the table's rules give them, and your own runs, schedules and webhooks see every row. A site function reaches only the tables of its site's one base; a table from another base behaves as if it does not exist. For another base's data, use an account function (it can only read what the caller may, unless you switch on Full data access).

Read Operations

await ctx.tables.getRows('Orders', { limit: 10, offset: 0, sort: 'createdAt', order: 'desc', filters: { Status: { $eq: 'active' } } })

Fetch rows with optional filters, sorting, and pagination.

await ctx.tables.getRow('Orders', 'row-id-123')

Get a single row by ID.

await ctx.tables.searchRows('Orders', 'search term')

Full-text search across text fields.

await ctx.tables.filterRows('Orders', { Status: 'active', createdAt: { $gte: '2026-10-01' } })

Filter by field values, up to 1,000 rows per call. createdAt and updatedAt filter by when a row was made or last changed, on any table.

await ctx.tables.getRowCount('Orders', { Status: 'active' })

Count rows. The filter is optional and works exactly like filterRows, so the count matches its total.

await ctx.tables.readAll('Orders', { filter: { Status: 'active' } })

Every matching row, without writing a paging loop: it reads 1,000 rows per call, up to 10,000 rows (set a lower maxRows if you like). If more rows match, it stops with a clear error instead of returning part of the list.

hiddenFields

When a field rule hides a field from the person a function runs for, the read answer lists that field's name in hiddenFields. A missing value there means hidden, not empty, so don't clear it.

await ctx.tables.report('Orders', { groupBy: [...], measures: [...] })

Counts, totals and averages per slice in one call. See ctx.tables.report.

await ctx.tables.listTables()

List all tables in your project.

await ctx.tables.getTableSchema('Orders')

Get field definitions for a table.

Write Operations

Every key must be a real field on the table, spelled exactly. If you write a key the table doesn't have, the write is rejected — nothing is saved, and the error lists the fields the table does have. It is never quietly skipped. A form with a Phone box needs a Phone field, or that answer has nowhere to go.

Field names are case-sensitive: email does not reach an Email field — it is an unknown field. A table can hold two fields whose names differ only by case, so matching loosely could put your value in the wrong one. For the same reason we never auto-correct to a similar name: only you know whether a key was a typo or a field you still need to create.
await ctx.tables.createRow('Orders', { Name: 'Alice', Status: 'active' })

Create a new row.

await ctx.tables.updateRow('Orders', 'row-id', { Status: 'shipped' })

Update a single row.

await ctx.tables.deleteRow('Orders', 'row-id')

Delete a single row.

await ctx.tables.upsertRow('Users', { matchField: 'Email', matchValue: 'alice@example.com', data: { Name: 'Alice' } })

Create or update based on a matching field.

Bulk Operations

await ctx.tables.bulkUpdateRows('Orders', [{ rowId: 'r1', data: { Status: 'shipped' } }, ...])

Update up to 100 rows in a single call.

await ctx.tables.bulkDeleteRows('Orders', ['row-1', 'row-2'])

Delete up to 100 rows in a single call.

Filter operators: $eq, $ne, $gt, $gte, $lt, $lte, $contains, $in, $isEmpty, $isNotEmpty

ctx.tables.report — Numbers in one call

Ask a numbers question about a table — how many, how many different, total, average, smallest, biggest, middle value — for the whole table or per slice (per status, per month, per country), in one call. There is no SQL: you fill in a short question and get back a plain list of slices with numbers. Never page through rows to add them up yourself.

A report counts only the rows the run may read, exactly what getRows would give it: when a visitor or member started the function, the numbers cover just their rows; your own runs, schedules and webhooks, and ctx.service.tables.report, cover every row. A field the person may not read can't be used, and sign-in and secret fields can never be used by anyone. From a page, entities.Orders.report({ ... }) asks the same question for the visitor.

// Revenue and paid share per month this year, in Berlin time
const r = await ctx.tables.report('Orders', {
  filter: { logic: 'and', conditions: [
    { fieldId: 'Created', operator: 'greaterThanOrEqual', value: '2026-01-01' },
  ] },
  groupBy: [{ as: 'month', field: { fieldId: 'Created' }, bucket: 'month' }],
  measures: [
    { as: 'orders', fn: 'count' },
    { as: 'revenue', fn: 'sum', field: { fieldId: 'Total' } },
    { as: 'paid', fn: 'count', filter: { logic: 'and', conditions: [
      { fieldId: 'Status', operator: 'equals', value: 'Paid' } ] } },
    { as: 'paidShare', fn: 'ratio', numerator: 'paid', denominator: 'orders', decimals: 2 },
  ],
  sort: [{ by: 'month', direction: 'asc' }],
  timeZone: 'Europe/Berlin',
});
// r.rows = [{ groups: { month: '2026-01-01' },
//             measures: { orders: 12, revenue: 4510.5, paid: 9, paidShare: 0.75 } }, ...]
// Top 10 sources by leads, with each source's share of all leads
const top = await ctx.tables.report('Leads', {
  groupBy: [{ as: 'source', field: { fieldId: 'Source' } }],
  measures: [{ as: 'leads', fn: 'count' }, { as: 'companies', fn: 'countDistinct', field: { fieldId: 'Company' } }],
  percentOfTotal: 'leads',
  sort: [{ by: 'leads', direction: 'desc' }],
  limit: 10,
});

measures (1–8): count, countDistinct (different non-empty values), sum, avg, min, max, median (number fields), ratio (one measure divided by another; empty when dividing by 0). A measure with its own filter counts only those rows. decimals (0–6) rounds it.

filter: the same filter language as views — { logic: 'and' | 'or', conditions: [{ fieldId, operator, value }] }, up to 30 conditions (a nested group counts as one) and 3 levels of groups. Dates compare as dates (write them as YYYY-MM-DD, optionally with a time).

groupBy (up to 3): any field with one value per record. Date fields take a bucket: day, week (starts Monday), month, quarter, year, hour (of the day, 0–23) or dayOfWeek (1 = Monday … 7 = Sunday), cut in timeZone (default UTC). A date without a time never moves a day. Empty values form one slice whose value is null.

having, sort (by a group or measure name), limit (1–1000, default 100) and offset page through slices; hasMore says more exist and total how many there are. percentOfTotal names a count or sum and adds each slice's share of all slices (0–1).

Limits: a report stops after 5 seconds with a plain message; very large answers are cut and say where (truncatedAt). A visitor-started run can report over at most 10,000 rows it may read. Not yet possible: grouping through a linked record, lookup and rollup fields, running totals, comparing periods in one report, cohorts — run one report per table (or per period) and combine the numbers in your function.

ctx.service — Admin Access

ctx.service.tables and ctx.service.files have the same methods as their regular counterparts but bypass all access rules. Use these for admin operations that need unrestricted access. They only work after a person turns on Full data access for that function: you, in the dashboard (Automations → Functions → the function → Access → Powers & secrets; in a workspace with members, the Owner or an Admin), or by tapping Apply on the card your AI shows in the chat when it asks for it (you then confirm it's you). The AI can't tap for you, and it can't say it is on before you do. The same card is how the AI changes such a function's code, secrets, allowed websites or who can run it.

Best Practice

Use ctx.service only in functions that require authentication (e.g., authenticated or has_role access rules) to ensure only authorized users can trigger admin-level operations.

// Example: admin function to get all orders regardless of access rules
const allOrders = await ctx.service.tables.getRows('Orders');
return allOrders;

ctx.fetch — External APIs

Make HTTPS requests to external services. Supports environment variable placeholders using {{VAR_NAME}} syntax.

// Call an external API with an env var for the API key
const result = await ctx.fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer {{API_KEY}}' },
  body: JSON.stringify({ query: params.query }),
  timeout: 10000,
});

// result = { status: 200, data: { ... } }
OptionDefaultDescription
methodGETHTTP method
headers{}Request headers (supports {{ENV_VAR}} placeholders)
body-Request body (auto-stringified if object)
timeout60000Timeout in ms for this one request (max 300,000 = 5 min). The function's own time limit still applies, so raise both for a slow call.

Security: Only HTTPS requests to public endpoints are allowed. Response size limited to 5MB.

ctx.files — File Operations

await ctx.files.list()

List the site's files that the person this run is for may read.

await ctx.files.getUrl('file-id')

Get a temporary download URL for a file, if that person may read it.

await ctx.files.upload('report.csv', csvContent, 'text/csv')

Upload a file with content and MIME type. ctx.files.getUploadUrl does the same for large files.

await ctx.files.delete('file-id')

Delete a file, if that person's delete rule on it allows.

When the function runs for a visitor or a signed-in member, list, getUrl and delete follow each file's own rules for that person (a file with no rule is not shared, except that its creator keeps their own delete), and upload and getUploadUrl follow the folder's “create” rule, or else the site's upload gate (signed in as a member of this site). Schedules, webhooks with no sign-in, your own runs and functions with Full data access see every file the site owns. More on Files.

ctx.ai — AI inside a function

One AI call with the site owner's models: the platform models (billed as the owner's AI credits) or their own provider keys from Your AI (billed nothing here). No tools, 60-second limit, up to 4,096 output tokens, low effort by default.

const { text } = await ctx.ai.generate({ prompt: 'Summarise in one line: ' + body, maxOutputTokens: 200 });

const r = await ctx.ai.generate({
  system: 'Reply with one word: billing, bug, or other.',
  messages: [{ role: 'user', content: ticket }],
  model: 'byoai:gpt-5.4@key:…',   // one of the owner's own keys; omit for the platform model
  effort: 'low',                   // 'low' | 'medium' | 'high'
});
// r = { text, model, finishReason, usage: { inputTokens, outputTokens }, credits, ownKey }

Ask for JSON in the prompt and parse r.text in a try/catch when you need structured output. Never put a provider key in code — the owner adds keys under Your AI and the model id carries the key.

ctx.connections — built-in connectors

The owner's saved sign-ins and API keys for other services (Google, GitHub, Slack, Stripe…), managed under Automations → Set up → built-in connectors. A function uses only the ones on its own list: open the function (your site → Backend Functions → the function), then Access, and tick the app under “Authorised connections”. Until one is ticked, ctx.connections.list() answers an empty list and a call is refused. You tick them (in a workspace with members, the Owner, an Admin or a Developer). The tool runs server-side and the secret never enters the function.

// list() shows only the apps ticked under this function's Authorised connections
const slack = (await ctx.connections.list()).find((c) => c.name === 'Slack');
if (!slack) return { error: 'Slack is not ticked under this function's Authorised connections' };
await ctx.connections.call(slack.id, 'send_message', { channel: '#support', text: 'New signup: ' + email });

ctx.env — Environment Variables

// ctx.env.get resolves to the value as a string
const value = await ctx.env.get('STRIPE_SECRET_KEY');

// Write your own working values. Names must start with APP_.
await ctx.env.set('APP_ONBOARDING_STAGE', 'complete');

A function can only read a secret that a person has ticked for it: open the function, go to Access, then Powers & secrets, and tick the secret under “Secrets this function can read”. A {{NAME}} placeholder that is not ticked is sent as the literal text. Reading any other setting with ctx.env.get needs the “Read and write secrets” power. A new function has no powers on. ctx.env.set only writes names starting with APP_, and it is refused if a function with Full data access reads that setting.

Environment variables are encrypted at rest and only decrypted when your function executes. Set them in your app's Config tab. See Environment Variables for more details.

ctx.email — Sending Email

Send transactional email from your own mailbox — the default one this site may use in Automations → Sending accounts. Runs as the project/owner, so it isn't gated by the project's member-facing email access rules.

await ctx.email.send({
  to: 'customer@example.com',       // string or array of strings
  subject: 'Your order shipped',
  html: '<p>Order #123 is on its way.</p>',
  text: 'Order #123 is on its way.', // optional
  replyTo: 'support@yourapp.com',    // optional
  template: 'order-shipped',         // optional — use a saved email template instead of html/text
  data: { orderId: '123' },          // optional — template variables
});

Capped at 50 recipients per call; no monthly quota from us (your mailbox's own hourly limit applies). See Email Templates.

A mailbox comes first. There is no built-in sender for email you write: with no mailbox this site may use, the send is refused (403 needsMailbox) and nothing is sent. A mailbox restricted to another site (“Restrict to one site”) does not count — the error names it and the fix.

To pick a mailbox by name — for newsletters, outreach or a second identity — use ctx.email.sendVia(), below.

Sending from your own mailbox

For outreach, newsletters or anything at volume, send through your own SMTP mailbox instead. Add it under Automations → Sending accounts, then send by name — no monthly platform quota applies, and mail leaves that mailbox's domain and sending reputation.

// which mailboxes are available (no credentials are ever returned)
const { scope, accounts } = await ctx.email.accounts();
// scope: 'member' = the signed-in member's own mailboxes (empty = THEY have none)
//        'account' = your account's mailboxes (no member signed in)
// accounts: [{ id, name, fromAddress, verified, isDefault }]
// Need your account's mailboxes while a member is signed in?
//   const { accounts } = await ctx.service.email.accounts();

await ctx.email.sendVia('outreach', {
  to: 'prospect@example.com',
  subject: 'Quick question',
  html: '<p>Hello…</p>',
  unsubscribeUrl: 'https://yoursite.com/unsubscribe?e=…', // always set for marketing
});
// → { success, messageId, skipped?, fromName, fromNameOverridden }
//   skipped = suppressed recipients

await ctx.email.sendVia(null, { to, subject, html }); // null = default mailbox

// One mailbox, several identities — set the display name per send.
// The ADDRESS never changes (it is the verified, SPF/DKIM-aligned one).
await ctx.email.sendVia('outreach', {
  to, subject, html,
  fromName: "Ken's Hardware Team",   // recipients see this name
});

The credential never reaches your function. You name a mailbox; the platform decrypts and sends. Function code cannot read the password — which is why SMTP credentials belong here rather than in environment variables.

Unsubscribes are honoured automatically. Recipients on the suppression list are dropped before any connection is attempted and returned in skipped. Each mailbox also has its own hourly send ceiling.

Sending accounts belong to your account, so agents, flows and automations can send too — a site isn't required. A sending account can optionally be bound to one site.

If the display name you set isn't what recipients see

fromNameOverridden: true means the platform put your name in the From header. It does not guarantee the recipient sees it. Some SMTP providers — shared cPanel/Exim hosting in particular — rewrite the sender on authenticated submission, replacing your per-send name with whatever display name the mailbox is configured with on their server. That happens after the message leaves us, so we cannot detect or prevent it.

To confirm, open a delivered message and read the raw source (in Gmail, Show original) rather than the sender line your mail client renders — clients substitute saved contact names for addresses you have emailed before. If the raw header shows the mailbox's name, the rewrite is at your mail host. Two fixes: set the display name per mailbox there, or relay through a provider that allows per-send names (Amazon SES, Postmark, Resend, Mailgun). Retrying the send will not change the result.

ctx.entitlements — Paid Access

The entitlement ledger backs the has_entitlement access rule. ctx.entitlements is the only way to write to it — grant access after a successful payment, then gate tables/files/functions/pages behind the same key. It works only after a person switches on Grant paid access for the function (Access tab, Powers & secrets): you, or in a workspace with members the Owner, an Admin, or a Developer given Give functions paid access (Team → their name → Permissions). Anyone else is told whom to ask. (In a workspace with members, Manage users stays with Developers too; Full data access is the Owner's or an Admin's only.)

await ctx.entitlements.grant(userId, 'pro')

Grant a user a key, e.g. after a webhook confirms payment.

await ctx.entitlements.extend(userId, 'pro', 30)

Extend an existing grant (e.g., a recurring subscription renewal).

await ctx.entitlements.suspend(userId, 'pro')

Temporarily pause access (e.g., failed renewal) without deleting the grant.

await ctx.entitlements.revoke(userId, 'pro')

Permanently remove a grant.

await ctx.entitlements.get(userId, 'pro')

Check a single user's current status for a key.

await ctx.entitlements.listForUser(userId)

List every entitlement a user currently holds.

Access Rules

Control who can execute your function. No rules configured = only the app owner can call it.

Rule TypeWho Can Execute
publicAnyone (no authentication required)
authenticatedAny logged-in user
has_roleUsers with a specific role (e.g., admin)

See Access Control for a complete guide on all rule types (including has_entitlement, function, and relationship).

Deny-by-default is strict. A function with no accessRules configured returns "No access rules configured" to everyone except the app owner — including logged-in members calling the function's HTTP endpoint directly. Always set execute rules before relying on a function from the published app.

The rule holds on every door

A function's own rule applies however a site user reaches it: a call from a page, a call from another function, a site method call, a queued run, a webhook that carries a site sign-in. A function with no rule is refused for site users on all of them (the one exception is an account function's secret webhook URL, where the URL itself is the only gate when no rule is set), so a helper that another function calls needs its own rule too. When another function, a site method call or a queued run reaches it, the visitor sees only “You don't have access to do that.” and you see the reason on the function's Runs. A direct call from a page answers with the refusal reason, for example “No access rules configured”. Your own runs, schedules and webhooks without a site sign-in are not site users and are not blocked.

Separately, Who can run this function says which doors may start it at all: a page directly, named functions, or agents. Set it under Automations → Functions → the function → Access. An AI agent can only narrow it. A new site function lets its own site's pages call it; a new account function lets nothing call it until you tick a door. Functions made before this setting keep working as they did, with every door ticked: untick what they don't need.

Limits & Security

LimitValue
Plan requirementPaid plans only — every execution path rejects free-plan accounts
Default timeoutA site function you create without a timeout gets 10 seconds; an account function gets 30 seconds. Either can be extended per function up to 5 minutes for slow work (AI calls, big imports)
Retries (maxRetries)0–10, default 0 (run once). Only applies when an automation/schedule invokes the function — set >0 only if it's idempotent
Monthly execution quotaPlan-based, but the counter is per account — shared across every project you own, not per-function or per-site
Database calls per executionSet by your plan (between 20 and 100, for example 60 on Pro and Business). Every ctx call except ctx.fetch counts, and each readAll page is one call. Read it in your code with ctx.limits.databaseCalls
Request body size5MB
Response size (ctx.fetch)5MB
Memory128MB, fixed on every paid plan
Filesystem accessBlocked (read/write denied)
Environment accessBlocked (use ctx.env instead)
Subprocess spawningBlocked

Each function runs in a completely isolated Deno process. One function cannot access another function's data or memory. Crashed functions don't affect the server.

Calling from Your App

In your published app's pages, import and call backend functions using the SDK:

// In your app's page code:
import { functions } from '../api/sdk';

const result = await functions.call('processOrder', {
  item: 'Widget',
  quantity: 3,
});

// result = { success: true, data: { orderId: '...' }, executionTime: 150 }

Functions are also reachable directly over HTTP (e.g., from an external webhook). The raw JSON request body is the params object — no wrapper:

POST /api/app-builder/{projectId}/functions/processOrder
Content-Type: application/json

{ "item": "Widget", "quantity": 3 }

// Response is the same shape: { success, data, executionTime }

The same access rules apply either way — an unauthenticated or under-permissioned external caller gets the same deny response a logged-out SDK call would.