Skip to main content
FlowFn
IntegrationsTemplatesPricingDocsBlogSign inStart free
All documentation

Server-side code (the Server tab)

UpdatedJul 3, 2026Reading time7 min read

What is server-side code?

The Server tab lets you write backend functions that run on FlowFn instead of in the visitor's browser. Your page calls them with flowfn.server.call. Use it for logic or secrets you don't want to ship to the page, or to do work the browser can't — read and write your data sheets, run a registered tool, or kick off an agent or workflow.

The Server tab is available on plans that include playground server-side code. When it's available you'll see a Server tab next to HTML, CSS, and JS.

Writing a function

Each function is attached to exports. It receives the validated inputs as the first argument and a ctx object as the second:

exports.subscribe = async (input, ctx) => {
  await ctx.sheets.insertRow('signups', { email: input.email });
  await ctx.workflows.run('<workflow_id>', { email: input.email });
  return { ok: true };
};

Declared inputs are the keys of that first argument — not the parameter names. In the example the caller passes { email: '…' }, so you declare one input named email (and read it as input.email). The first parameter — call it input, args, anything — is the whole object; the second parameter ctx is injected by FlowFn and is never a declared input. A common mistake is to write async (args, ctx) => and then declare inputs named args and ctx: the engine then expects { args, ctx } and rejects your real fields with "unknown input key" while reporting args / ctx as missing. Declare the data fields (name, email, …), never the parameters.

To run one of your bound actions from inside a function, use ctx.tools.invoke('@key', { … })not flowfn.invoke, which is the page-side SDK and is undefined in the server sandbox.

What ctx gives you:

  • ctx.sheetslist(), listRows(slug), query(slug, { where }), getRow(slug, id), insertRow(slug, cells), updateRow(slug, id, cells), deleteRow(slug, id) on this playground's data sheets. getRow fetches one row by its id (returns null if absent); to look a row up by id, use getRow — not a query filter, which matches columns.
  • ctx.tools.invoke(name, inputs) — run an action you bound to a @key. ctx.fetch(ref_key, inputs) — run an entry from the Fetch tab. (Server code can only use what you've already configured on this playground; it can't make arbitrary outbound requests.)
  • ctx.agents.run(agent_id, input) and ctx.workflows.run(workflow_id, input) — start an agent or workflow. These return a run id; the run continues in the background.
  • ctx.auth (only when end-user auth is enabled) — login, signup, logout, changePassword, requestPasswordReset, resetPassword, plus currentUser() for the signed-in visitor. currentUser() returns { userId, user, cells } | null; login()/signup() return { userId, user }. The user object (and currentUser's identical cells alias) is the user row's cells keyed by your user-sheet column with secured columns redacted — there is no top-level user.email unless you have a column named email. Read a field by its column, e.g. const me = await ctx.auth.currentUser(); const email = me && me.user.email;.

The Functions panel

Open Functions on the Server tab to declare each function you want to call:

  • Name — must match an exports.<name> in your code.
  • Enabled — turn a function off to stop it responding without deleting it.
  • Inputs — the keys a caller may pass inside the first argument, each with a name and type (e.g. email, name). This is the allowlist a call's arguments are validated against: unknown keys are rejected, and a function with no declared inputs only accepts an empty arguments object. Declare the data fields your code reads off the first argument — never the parameter names, and never ctx.

Declaring an enabled function here is what makes it callable — both from your own editor preview and from visitors of your published page. Anything you export in code but don't declare in this panel stays private to you.

Whatever your function returns is sent back to the caller — shape the response in code; there's no separate output mapping to configure.

Once a function is named, Add to code drops a starter exports.<name> stub (with any inputs you declared) into the Server tab for you to fill in — so you never have to write the boilerplate by hand. The button disappears once the function exists in your code.

Calling from your page

const result = await flowfn.server.call('subscribe', { email: emailInput.value });
// → whatever the function returns

On failure it rejects with an Error whose .code tells you what happened (not_found, invalid_inputs, rate_limited, daily_cap_reached, TIMEOUT, RUNTIME_ERROR). Any message you throw inside the function is passed through, so you can show it to the user.

End-to-end example: page → server → workflow

A contact form that emails your team. Email tools are owner-only and can't run inside a playground (page or server sandbox), so the email is sent by a workflow the server function triggers. Four pieces wire together: a form on the page, an email workflow, a server function that triggers it, and the page JS that calls the function. Build them in this order.

1. The form (HTML tab). Plain inputs — no @keys needed on the fields, because the page JS reads them directly.

<form id="contact">
  <input name="name" required>
  <input name="email" type="email" required>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

2. The email workflow. Build a workflow that takes name, email, and message and sends the email with your email tool — the "Playground contact → Resend reply" template is a ready-made starting point. Publish it, then copy its workflow id from the workflow's page. (Email and other owner-only tools aren't callable from a playground directly — a workflow is how a playground performs them.)

3. The server function (Server tab). Open Functions, add a function named sendContact, and declare three inputs: name, email, message (all string). These are the keys the page sends — not the parameter names, and never ctx. Then write it:

exports.sendContact = async (input, ctx) => {
  // input is the object the page passed; its keys are your declared inputs.
  // Hand off to the email workflow — email tools can't run inside a playground.
  // Pass the workflow id you copied in step 2.
  await ctx.workflows.run('<workflow_id>', {
    name: input.name,
    email: input.email,
    message: input.message,
  });

  return { ok: true };
};

4. Call it from the page (JS tab). Collect the fields and hand them to the function — the keys must match the inputs you declared in step 3.

document.getElementById('contact').addEventListener('submit', async (e) => {
  e.preventDefault();
  const f = e.target.elements;
  try {
    const res = await flowfn.server.call('sendContact', {
      name: f.name.value,
      email: f.email.value,
      message: f.message.value,
    });
    if (res.ok) e.target.reset();
  } catch (err) {
    alert(err.message); // e.g. 'invalid_inputs' if a declared field is missing
  }
});

How it flows: the page sends { name, email, message } → the engine validates them against sendContact's declared inputs → your function calls ctx.workflows.run('<workflow_id>', …) → the workflow sends the email with your email tool, running as your team (credentials never reach the visitor) → the function returns { ok: true } to the page. The visitor's browser never sees your workflow id, the tool connection, or your email address.

Who can call your functions

Every function you declare and enable in the Functions panel can be called both by you (signed in, on the team that owns the playground — for example while testing in the editor preview) and by anyone who can open your published page. There's no separate switch to flip: declaring a function is the act of publishing it.

The function always runs as your team — it can use your sheets, tools, and start agents/workflows on your behalf — so only declare a function for work you're comfortable letting your page's visitors trigger. Calls from visitors validate their inputs against the declared inputs, are rate-limited per visitor, and have a daily cap per playground. To take a function offline, switch it off with Enabled or remove it from the panel; code you export but never declare here is never reachable from the page.

Get help from AI

The AI assistant can write and refine Server-tab code for you, the same way it helps with your page's HTML, CSS, and JavaScript. Describe what you want the backend to do and it will draft the function.

Good to know

  • Server code never reaches the browser — visitors can't read it.
  • Functions run with a time budget (about 25 seconds) and a cap on how much work one call can fan out, so design long-running jobs as a workflow you start from the function rather than doing everything inline.
  • Public calls aren't automatically de-duplicated — if a function has side effects (inserts a row, sends a message), design it to be safe to run twice in case a visitor's network retries.
  • Because a function runs as your team, it can read and write your sheets even when that sheet's public insert / update / delete is turned off. Those per-sheet toggles only block direct writes from a visitor's browser — a server function is your own code, so it writes on your behalf. If you don't want a sheet written from the page at all, don't declare a function that writes it.
  • The standard public access policy still applies: the playground must be published, and password / embed-domain gates apply to server calls just like they do to the page.

Spotted an issue or have feedback?

support@flowfn.com
Back to docs hub →