Skip to content

Develop Functions and actions

Implement an explicit server-side operation that can be invoked by a form action or the named action API.

Use a Function for a named operation such as posting an order, recalculating totals, approving a record, or allocating stock. Use hooks/events for lifecycle behavior and Scripts for registration of several related handlers.

sequenceDiagram
participant U as User or client
participant A as /api/action/:name
participant K as Kernel action registry
participant C as Authenticated DataContext
participant DB as SQLite
U->>A: POST action with args
A->>K: Resolve action name
K-->>A: Function handler
A->>C: Create authorized context
A->>C: Run transactional handler or await async handler
C->>DB: Validate, authorize, read/write in explicit boundaries
DB-->>C: Commit or rollback each transaction
C-->>A: Function result
A-->>U: JSON response or error
  • A table, form, or other metadata target for the operation.
  • A privilege that includes the Function.
  • A test user with both allowed and denied permissions.
{
"kind": "function",
"name": "SALES_PostOrder",
"app": "sales",
"label": "Post order",
"executionMode": "transactional",
"code": "const orderId = Number(args.orderId); const order = ctx.find('SALES_Order', orderId); if (!order) throw new Error('Order not found'); if (order.f.status !== 1) throw new Error('Only open orders can be posted'); order.set('status', 2).update(); return { ok: true, orderId };"
}

FunctionMeta.executionMode is optional and accepts "transactional" or "async". The default is "transactional".

The body is invoked with (ctx, args, kernel, services):

  • ctx is the authenticated DataContext for the request.
  • args contains the JSON action arguments.
  • kernel is the active runtime and registry.
  • services provides bounded HTTP and email integrations.

The return value becomes the API response. An undefined result is returned as { ok: true }.

{
"label": "Post order",
"type": "function",
"target": "SALES_PostOrder"
}

The endpoint is:

POST /api/action/:name

Unknown action names return HTTP 404. The server resolves the action, checks the Function privilege, and creates an authenticated context. A form or line action whose Function the user is not allowed to run is omitted from GET /api/metadata, and calling the endpoint directly returns 403.

Mode Use it for Transaction behavior
transactional Synchronous validation and related database writes The action route wraps the entire handler in one transaction. Throwing rolls it back.
async HTTP, SMTP, or other work that requires await No automatic transaction spans the handler. Use short ctx.tts() blocks for related database work.

Never return a Promise from a transactional Function. Since v1.4.0 the transaction rejects it with Transaction: async lifecycle handlers are not supported, and the callback passed to ctx.tts() must be synchronous for the same reason. Never wait for HTTP or email inside ctx.tts(); holding a SQLite transaction open during network I/O increases lock time and can cause contention.

Because hooks and data events must also be synchronous, an async Function is the supported place for awaited or external work that a lifecycle rule needs. Call it explicitly from a Form action, or from another process, rather than from inside a hook. See Record lifecycle.

Use the supplied context for all reads and writes. In transactional mode, the action route wraps the handler in a transaction. Nested ctx.tts() calls use savepoints. Any thrown error rolls back the current transaction or savepoint.

In async mode, group each atomic set of database changes in a synchronous ctx.tts() callback. Complete network work before or after that callback:

const response = await services.http.request({
url: String(args.url),
timeoutMs: 10000
});
if (!response.ok) {
throw new Error(`Remote service returned HTTP ${response.status}`);
}
return ctx.tts(() => {
const log = ctx.newRecord('SALES_IntegrationLog');
log.setMany({
status: response.status,
responseText: response.text
}).insert();
return { ok: true, logId: log.id };
});
return ctx.tts(() => {
const header = ctx.newRecord('SALES_Order');
header.setMany({
orderNumber: String(args.orderNumber),
status: 1,
customerId: Number(args.customerId)
}).insert();
for (const line of args.lines ?? []) {
ctx.newRecord('SALES_OrderLine').setMany({
orderId: header.id,
productId: Number(line.productId),
quantity: Number(line.quantity),
unitPrice: Number(line.unitPrice)
}).insert();
}
return { ok: true, orderId: header.id };
});

A Function can ask the user for images that belong to a record. Declare imageInput on the Function:

{
"kind": "function",
"name": "SALES_ScanReceipt",
"app": "sales",
"label": "Scan receipt",
"imageInput": { "table": "SALES_Order", "recordIdArgument": "recordId", "multiple": true },
"code": "const ids = args.attachmentIds; return { ok: true, orderId: args.recordId, images: ids.length };"
}
Property Meaning
table Business Table that owns the record. It must exist and cannot be a FW_* table.
recordIdArgument Name of the argument that carries the record ID. It cannot be attachmentIds, __proto__, constructor, or prototype.
multiple When true, several images are accepted. Otherwise exactly one image is required.

The Function dialog lets the user choose image files or take a photo with the camera. The images must be JPEG, PNG, or WebP. They are uploaded when the user confirms, as attachments of the record, and then the Function runs.

  1. The client uploads each image to POST /api/attachments/:table/:id/file?function=<name>&uploadId=<uuid>. The uploadId is generated by the client, so a retry of one file never creates a duplicate. If one upload fails, the others remain attached and only the failed file is retried.
  2. The client calls POST /api/action/<name> with recordId (under the name you chose) and attachmentIds.
  3. The server checks that the user may run the Function, that the user can update the record, that each attachment belongs to that record and was uploaded by the same user, and that the file content matches an allowed image type. A violation returns 403 or 422, and the Function does not run. At least one image is required, duplicates are rejected, and multiple: false accepts exactly one.
  4. The Function receives args.attachmentIds, an array of attachment IDs, and args.recordId as a number. Read file details through the attachments API.

Images stay attached to the record when the Function itself fails; remove them with DELETE /api/attachments/:attachmentId if the business process requires it. GET /api/metadata publishes name, label, and imageInput for each permitted Function in functionInputs, which lets a client decide when to show the image dialog. Function code is never sent to the client.

services.http.request(input) accepts:

Field Type Notes
url string Required; only http: and https: are accepted.
method string Optional; defaults to GET without a body or POST with a body.
headers Record<string, string> Optional request headers.
json unknown Optional JSON body; cannot be combined with text.
text string Optional text body; cannot be combined with json.
timeoutMs number Defaults to 15,000 ms and is capped at 60,000 ms.

The Promise resolves to { status, ok, headers, text, json }. json contains the parsed response when possible and is otherwise null. A non-success HTTP status does not reject the Promise; check ok or status. Connection errors, timeouts, invalid protocols, and responses larger than 5 MB reject it.

{
"kind": "function",
"name": "SALES_SendOrder",
"app": "sales",
"label": "Send order",
"executionMode": "async",
"code": "const response = await services.http.request({ url: String(args.endpoint), method: 'POST', headers: { authorization: String(args.authorization) }, json: { orderId: Number(args.orderId) }, timeoutMs: 15000 }); if (!response.ok) throw new Error(`Remote service returned HTTP ${response.status}: ${response.text}`); return { ok: true, remote: response.json };"
}

Do not put credentials directly in Function metadata. Supply secrets through a controlled server-side mechanism and avoid returning remote headers or bodies that contain sensitive data.

Configure SMTP under Settings → SMTP Settings before calling services.email.send(input). The input supports to, cc, and bcc as a string or string array; replyTo; a required subject; and at least one of text or html.

At least one to recipient is required. A message is limited to 50 total recipients and 1 MB across the subject and text/HTML content. The SMTP connection requires TLS 1.2 or later and rejects invalid certificates.

The Promise resolves to { messageId, accepted, rejected }. Inspect rejected when the caller needs to confirm delivery acceptance.

{
"kind": "function",
"name": "SALES_EmailReceipt",
"app": "sales",
"label": "Email receipt",
"executionMode": "async",
"code": "const result = await services.email.send({ to: String(args.to), subject: `Receipt ${args.receiptNo}`, text: `Your receipt total is ${args.total}.`, html: `<p>Your receipt total is <strong>${args.total}</strong>.</p>` }); if (result.rejected.length) throw new Error(`SMTP rejected: ${result.rejected.join(', ')}`); return { ok: true, messageId: result.messageId, accepted: result.accepted };"
}

SMTP failures, missing configuration, invalid input, encryption-key problems, timeouts, and certificate errors reject the Promise. Catch an error only when the Function can add safe context or perform a deliberate recovery; otherwise let the route return the failure.

The Function must be listed in a privilege assigned through a duty or role. Use the authenticated ctx; never create an unrestricted context to bypass permissions. Validate access to the target record and return user-correctable problems as ValidationError. Do not expose secrets, SQL details, or internal stack traces.

Functions are dynamic server-side code. Treat them as trusted administrative code, review changes, restrict Designer access, use unique names, and use only the bounded services interfaces for supported HTTP and email integrations. Keep complex reusable or native integrations in reviewed TypeScript.

Test successful input, invalid input, missing records, unauthorized users, image uploads that fail midway or are retried, rollback after a failed related write, and duplicate action names. For async Functions, also test connection failure, timeout, non-success HTTP status, oversized response, missing SMTP configuration, rejected recipients, and database state when a service fails before or after an explicit transaction. When a Function fails, verify the exact action name, request args, execution mode, service configuration, table permissions, record lookup, field types, transaction boundaries, and whether a Script registered the same action.

Scripts · Hooks and data events · Attachments · Record lifecycle · Security · Testing