Skip to content

Server Functions (Node.js)

For creating server functions, the detail view UI, security configuration, and logs, see Server Functions in the User Guide.

Server Functions run as Node.js modules outside the database. Use them for work that cannot run inside PostgreSQL: calling external HTTP APIs, sending email, working with AI models, handling files, processing webhooks, or anything that may take time or cross a network boundary. They share the same api data access surface as Database Workflows but run asynchronously and hold no database connection for their full duration.


Every Server Function follows a four-section layout: METADATA, CONSTANTS, PRIVATE_FUNCTIONS, and HANDLERS. When a new function is created in the platform, the editor is pre-populated with a working template following this structure.


  • When the caller includes a cmd field, the platform invokes the handler with that name.
  • When cmd is omitted, the run handler is called.
  • Each handler receives input - the request payload with cmd removed.
  • Handler names use snake_case.
  • Prefer async handlers for any I/O.

The __meta block declares this function’s identity and dependencies:

FieldDescription
thing_idStable UUID for this function - required
can_deleteWhether records created by this function can be deleted
function_targetsThing IDs of other functions called via api.execute_function
view_targetsThing IDs of views read via api.get_data
entity_targetsThing IDs of entities read or written
component_targetsThing IDs of components read or written

The AI assistant keeps __meta up to date automatically as you develop. You can also ask it explicitly: “update the meta object to reflect all targets in this code.”


Adding JSDoc to handlers is how the platform discovers the function’s public interface at publish time:

/**
* Send a confirmation email for an order.
* @param {{ thing_id: string, recipient_email: string }} input
* @returns {Promise<void>}
*/
handlers.send_confirmation = async input => {
// ...
};

After publishing, the platform extracts handler names, descriptions, and parameter shapes. This powers the Published methods picker in Run Function and the method selector in workflow Execute Server Function steps. The AI can write JSDoc for you - ask it to “add JSDoc to all handlers.”


The AI/Code tab opens in chat mode. Describe what the function should do and the AI generates Node.js code following the structure above, including JSDoc on handlers. Click Apply to push a suggestion into the editor.

Before prompting, add the entities, views, and functions the AI should work with via the Context picker. The AI does not auto-discover your data model - without context it cannot reference the correct fields, view parameters, or function signatures. The more relevant context you add, the more accurate the generated code will be.

Use See Code to review or modify code directly. Switch back to Use AI at any time to continue iterating.

The AI is aware of the full api.* surface available in Node - including api.http, api.ai, api.files, api.mail, and api.secrets. It will suggest the appropriate method for the task and handle the async/await pattern correctly.


api is available automatically - no import needed. All platform operations require await:

const rows = await api.get_data({
target: constants.order_status_view,
params: { thing_id: input.thing_id }
});

The data access methods are identical to those in Database Workflows, with one difference: all calls require await.

// Load from a view
const rows = await api.get_data({ target: view_id, params: {} });
// Query an entity/component directly
const records = await api.query_data({
target: entity_id,
filter: `status = ` + await api.quote_literal(api.constants.statuses.live)
});
// Save, submit, delete, clone
await api.save_data({ target: entity_id, item: record });
await api.save_and_submit({ target: entity_id, item: record });
await api.submit({ target: entity_id, item: { thing_id, version_id } });
await api.delete_data({ target: entity_id, item: { thing_id, version_id } });
const cloned = await api.clone_data({ source: { thing_id }, item: template });
const draft = await api.clone_version({ source: { thing_id, version_id } });
// Call another function
const result = await api.execute_function({
target: constants.my_fn,
cmd: `handler_name`,
input: { thing_id }
});

See Database Workflows - Data access for the full field reference on each method.


const user = await api.current_user();
if (user.anonymous) {
throw new Error(`Authentication required`);
}
if (await api.is_feature_enabled(`my_feature`)) {
// ...
}

api.constants provides the same statuses and entity_types reference values as in PLV8:

filter: `status = ` + await api.quote_literal(api.constants.statuses.live)

  • await api.http.get(input)
  • await api.http.post(input)
  • await api.http.put(input)
  • await api.http.patch(input)
  • await api.http.delete(input)
  • await api.http.head(input)
  • await api.http.get_stream(input) / await api.http.post_stream(input)
FieldRequiredDescription
urlyesRequest URL
headersnoHeaders object
querynoQuery string parameters object
bodynoRequest body
timeout_msnoTimeout in milliseconds
response_typenojson (default), text, xml, form, html
full_responsenoWhen true, return full response with status and headers
authno{ type: 'basic', username, password } or { type: 'bearer', token }
on_datastream onlyRequired callback for stream methods
const data = await api.http.post({
url: `https://api.example.com/orders`,
headers: { Accept: `application/json` },
body: { order_id: input.order_id },
timeout_ms: 30000
});

Server functions can be configured as handlers for Custom Routes - HTTP endpoints for receiving webhooks, serving API responses, or handling file downloads. When invoked this way, the input includes req and res objects for reading the request and writing the response. These are not present when a function is called from a Database Workflow, a portal action, or the platform UI.

Implement a process_request handler as the entry point. If process_request is not defined, the platform falls back to run. Use exactly one response pattern per request.

handlers.process_request = async input => {
const { req, body } = input;
if (req.method !== `POST`) {
return api.http_error.method_not_allowed(`Method not allowed`);
}
const result = await do_work(body);
return { ok: true, data: result };
};

input includes: req (method, headers, path), res, query (parsed query string), body (parsed request body).

Return-driven - return JSON or an api.http_error. Do not write to input.res.

return { ok: true, id: created.thing_id };
return api.http_error.bad_request(`Missing required field`);

Response-driven - write directly to input.res via api.webhook helpers. Return nothing after the response is written.

await api.webhook.stream_file({
res: input.res,
file_path: export_path,
content_type: `text/csv`,
content_disposition: `attachment; filename=export.csv`
});
await api.webhook.redirect({ res: input.res, location: redirect_url, status: 302 });
await api.webhook.send({ res: input.res, content_type: `text/plain`, body: `ok` });
MethodStatus
api.http_error.bad_request(msg)400
api.http_error.unauthorized(msg)401
api.http_error.forbidden(msg)403
api.http_error.not_found(msg)404
api.http_error.method_not_allowed(msg)405
api.http_error.conflict(msg)409
api.http_error.unprocessable_entity(msg)422
api.http_error.too_many_requests(msg)429
api.http_error.internal(msg)500
api.http_error.custom(status, code, msg, details?)any

Text and multimodal generation.

FieldDescription
messagesArray of message items (preferred)
systemSystem prompt
model, providerDefault provider: openai
optionsProvider-specific options (e.g. temperature)
stream: true + on_streamEnable streaming with a chunk callback

Returns { success, data, meta, error }. Check success before using data:

const result = await api.ai.generate({
messages: [`Summarize this order in one sentence.`, JSON.stringify(order)],
options: { temperature: 0.2 }
});
if (!result.success) {
throw new Error(`AI failed: ${result.error.message}`);
}
return result.data;

Generate embeddings. input is a string or string array. Same envelope as generate.


MethodDescription
await api.secrets.get({ secret_id, encryption_key? })Returns the secret value or null
await api.secrets.set({ secret_id, secret_value, encryption_key?, read_only? })Create or update
await api.secrets.delete({ secret_id })Returns true if deleted, false if not found
const token = await api.secrets.get({ secret_id: `my_integration_token` });
if (!token) throw new Error(`Integration token not configured`);

Required: name, data, mime_type. Optional: extension, description, is_public, encoding, link: { thing_id, version_id }.

Returns file metadata including thing_id, version_id, uri, and public_url_expires.

Required: url. Downloads and stores the file. Same return shape as save.

Returns file metadata plus content (inline bytes) or uri (URL-backed).

const file = await api.files.save({
name: `export.csv`,
data: csv_content,
mime_type: `text/csv`
});
await api.mail.send({
to: `user@example.com`,
subject: `Your export`,
html: `<p>See attached.</p>`,
attachments: [file.thing_id]
});

FieldRequiredDescription
toyesRecipient address or addresses
subjectyesEmail subject
html or textyesAt least one body format
cc, bccnoAdditional recipients
from, from_namenoOverride sender; defaults from platform email settings
attachmentsnoArray of file thing_id values
ical_eventno{ file_name, content } - calendar invite

Returns { message_id, accepted, rejected, response }. Throws on failure.

await api.mail.send({
to: `customer@example.com`,
subject: `Order confirmed`,
html: `<p>Your order has been confirmed.</p>`,
attachments: [invoice_file_thing_id]
});

MethodLevel
await api.log({ log_level?, message?, data? })configurable (default info)
await api.log_debug(message?, data?)debug
await api.log_info(message?, data?)info
await api.log_warn(message?, data?)warn
await api.log_error(message?, data?)error
await api.log_info(`Order processed`, { thing_id: input.thing_id });
try {
await api.save_data({ target: constants.order_entity, item: record });
} catch (err) {
await api.log_error(`Save failed`, { message: err.message });
throw err;
}

Log entries appear in the Logs tab of the function detail view in the platform.


const id = await api.new_uuid();
const hex = await api.md5(`some_text`);
const deterministic_id = await api.md5_uuid(`prefix_` + counter);
const copy = await api.clone(original_record);
const safe = await api.quote_literal(input.thing_id);

KindExample
Handlerget_status, send_confirmation
Entity constantorder_entity
Component constantorder_lines_component
Function constantsubmit_order_fn
View constantorder_status_view
Record variableorder_record
Record arrayorder_records

  • Keep handlers imperative - avoid classes, repositories, or service layers.
  • Validate input early; throw clear errors.
  • Always include a run handler as the default entry point.
  • All platform data access goes through api - no raw SQL or direct DB connections.
  • Use await api.quote_literal for dynamic values in query_data filters.
  • Check api.ai.* results via success; all other api methods throw on failure.
  • Add JSDoc to handlers before publishing so the platform can discover their interface.