Server functions
Screens run in the browser and can't call other services or hold secret keys. A server function is a small piece of your own code that runs on the server when a screen asks: call a weather API, charge a card with your Stripe key, run a check the browser must not be trusted with. The screen gets back the JSON you return.
When to use it
- A third-party API outside the integration catalog.
- Anything that needs a secret key.
- A calculation or check you want done on the server.
Step by step
- Declare a workflow with
"on": { "call": true, "roles": [...] }and onecustomstep. - Write the step as the body of
async (ctx, input, user) => { … }and return any JSON value. - If it needs a key, add it under Backend, Secrets and read it with
ctx.secrets.get. - Call it from a component with
await app.call("id", input).
Code
The booking app checks the weather for garden classes with Open-Meteo (no key needed):
{
"id": "class-forecast",
"title": "Weather for a garden class",
"on": { "call": true, "roles": ["member", "instructor"] },
"do": [{ "action": "custom", "code": "<the body below>" }]
}const id = String(input.session_id ?? "");
if (!id) throw new Error("Which class? Send a session_id.");
const session = await ctx.db.get("session", id); // as the caller: access applies
if (!session) throw new Error("That class isn't on the schedule.");
const hour = new Date(String(session.starts_at)).toISOString().slice(0, 13) + ":00";
const day = hour.slice(0, 10);
const url = "https://api.open-meteo.com/v1/forecast?latitude=38.7223&longitude=-9.1393"
+ "&hourly=temperature_2m,precipitation_probability,weather_code&timezone=UTC"
+ `&start_date=${day}&end_date=${day}`;
const res = await ctx.http.fetch(url);
if (!res.ok) throw new Error(`The forecast service answered ${res.status}. Try again later.`);
const data = res.json<{ hourly?: { time?: string[]; temperature_2m?: number[];
precipitation_probability?: number[]; weather_code?: number[] } }>();
const h = data?.hourly;
const i = h?.time ? h.time.indexOf(hour) : -1;
if (!h || i < 0) return { tempC: null, rainChance: null, summary: "No forecast yet" };
const code = h.weather_code?.[i] ?? 0;
const summary = code === 0 ? "Clear sky" : code <= 3 ? "Some cloud" : code <= 67 ? "Rain likely" : "Showers";
return { tempC: h.temperature_2m?.[i] ?? null, rainChance: h.precipitation_probability?.[i] ?? null, summary };And from the screen:
type Forecast = { tempC: number | null; rainChance: number | null; summary: string };
try {
const f = await app.call<Forecast>("class-forecast", { session_id: id });
setForecast(`${f.summary}, ${Math.round(f.tempC ?? 0)}°C`);
} catch (e) {
setForecast(e instanceof Error ? e.message : String(e)); // your thrown message
}On the live app this answered {"tempC":16.6,"rainChance":0,"summary":"Some cloud"}.
With a secret key
const key = await ctx.secrets.get("WEATHER_API_KEY");
if (!key) throw new Error("Weather isn't set up yet");
const res = await ctx.http.fetch(url, { headers: { authorization: `Bearer ${key}` } });What the code gets
| Name | Value |
|---|---|
input | The object the screen sent (up to 64 KB). Narrow it with String(input.x) |
user | { id, name, email, role } of the caller, or null when signed out |
ctx.db | get / list / create / update / remove, as the caller |
ctx.http.fetch | HTTPS requests, 30 seconds each. An object body is sent as JSON |
ctx.secrets.get | The project's secrets (production values, in dev and prod) |
| Also | ctx.ai, ctx.integrations.call, ctx.notify, ctx.email, ctx.vars, ctx.log, ctx.now |
Gotchas
- Who may call:
roleslimits it to those personas; without roles any signed-in user may; add"public": truefor signed-out visitors. The server decides from the session, never from the browser. - Never return or throw a key. Thrown messages are shown to the caller.
- One attempt, 30 seconds. No retries; a slow call rejects with "didn't finish".
- Each call is a workflow run: it counts against the app's daily run quota and shows in the logs with your
ctx.loglines. - Third-party services fail. Open-Meteo answered 503 once while we built the tutorial; the screen showed the thrown message. Always handle a rejected
app.call. - Up to 8,000 characters, strict TypeScript. A function that doesn't check is dropped and
check_contractsays why. - No global
fetch, imports, timers orprocess.