Skip to main content

Writing Functions

Every function is a single JavaScript file that declares a top-level handler:

functions/my-function/src/handler.ts
async function handler(input, context) {
// your logic
return { success: true };
}

That's the whole contract โ€” no export, no framework, no wrapper. The file you deploy is the built output (dist/handler.js); the CLI inlines it into your app version on epilot app deploy. Scaffolding via epilot app add-function gives you a TypeScript setup that compiles to exactly this shape.

The input objectโ€‹

What your handler receives depends on the trigger:

{
// Always present
trigger: {
type: "workflow" | "schedule",
// scheduled runs additionally carry:
schedule?: "rate(30 minutes)",
scheduled_time?: "2026-08-18T03:00:00Z"
},
app_options: {
token: "<epilot app token>", // see "Calling epilot APIs" below
stage: "prod", // deployment stage, for deriving API base URLs
...optionValues // plain (non-secret) option values of the installation
// + secret option values you explicitly declared in the function's `secrets` list
},

// Workflow runs
entity: { _id, _schema, ... }, // the entity the flow ran on
action_config: { app_id, ... }, // the action's configuration from the flow builder,
// including values from your custom config UI

// Scheduled runs
org_id: "739224", // the installation this run belongs to
app_id: "your-app-id"
}

The context objectโ€‹

context.epilot  // the full @epilot/sdk, pre-authorized with your app token
context.fetch // standard fetch() for everything else

The epilot SDK is bundled into the sandbox โ€” you don't import or install anything, and epilot.authorize() is already called with input.app_options.token. Every epilot API is available as context.epilot.<api>.<operationId>(...), fully typed against the platform's OpenAPI specs.

console.log() output is captured per run and available for debugging.

Return valuesโ€‹

ReturnEffect
any object, e.g. { success: true }Run succeeded
{ skip_reason: "..." }Run intentionally skipped โ€” a workflow action is marked skipped, a scheduled run counts as skipped
{ error_reason: "..." }Run failed โ€” a workflow action fails the flow step, a scheduled run is recorded as an error
throwing an exceptionSame as error_reason, with a generic message shown to the user

Calling epilot APIsโ€‹

Use the bundled SDK โ€” no auth wiring, no base URLs, typed operations:

async function handler(input, context) {
const { epilot } = context;

// Search entities
const { data } = await epilot.entity.searchEntities(null, {
q: "_schema:opportunity AND _exists_:my_field",
size: 25,
});

// Update an entity
for (const entity of data.results ?? []) {
await epilot.entity.patchEntity(
{ slug: "opportunity", id: entity._id },
{ my_field: "synced" }
);
}

return { success: true, updated: data.results?.length ?? 0 };
}

Everything the SDK offers is there: epilot.entity, epilot.pricing, epilot.workflow, epilot.message, โ€ฆ โ€” see the SDK reference for the full list of APIs and operations.

What the SDK runs as: the SDK is pre-authorized with input.app_options.token โ€” an app token, short-lived, minted per run, scoped to the installing organization and to exactly the permissions your manifest declares. If your manifest declares entity:view on opportunity, that is all your function can do โ€” in that organization's data, never anyone else's.

Non-production stages

The bundled SDK targets epilot's production APIs. When testing an app against a non-production epilot environment, call the APIs with context.fetch instead, deriving the base URL from input.app_options.stage (e.g. https://entity.${stage}.sls.epilot.io) and sending Authorization: Bearer ${input.app_options.token} yourself. In customer organizations (production) the SDK is always the right tool.

Calling external APIsโ€‹

Route external calls through your app's API Proxy component. Credentials (API keys, OAuth secrets) are configured per installation as secret options and injected server-side by the proxy โ€” your function never sees them.

The nicest way is the proxy wrapper from @epilot/app-sdk โ€” the same one your frontend components use. Since function code ships as a single file, bundle your handler (e.g. with esbuild) so the import is inlined:

import { createProxyClient } from "@epilot/app-sdk";

async function handler(input, context) {
const client = createProxyClient({
appId: input.app_id,
token: input.app_options.token,
});

const order = await client.proxy("my-api", "/orders/4711");
const created = await client.proxy("my-api", "/reservations", {
method: "POST",
body: { slot: "2026-09-01T10:00" },
});
// ...
}

If your build is plain tsc (no bundler), call the proxy URL directly with context.fetch โ€” same request, just hand-rolled:

const { token, stage } = input.app_options;
const base = `https://app${stage !== "prod" ? `.${stage}` : ""}.sls.epilot.io`;
const res = await context.fetch(
`${base}/v1/public/app/${input.app_id}/proxy/my-api/orders/4711`,
{ headers: { Authorization: `Bearer ${token}` } }
);

Both forms hit POST/GET โ€ฆ/v1/public/app/{appId}/proxy/{proxyName}/{path} with the app token as Bearer โ€” the proxy injects the real credentials server-side. (The wrapper also takes baseUrl if you target a non-production stage.)

If a function genuinely needs a raw secret (rare โ€” prefer the proxy), declare it explicitly:

{ "name": "my-function", "type": "workflow", "handler": "...", "secrets": ["api_signing_key"] }

Only the listed keys are decrypted into input.app_options; everything else stays sealed.

Limitationsโ€‹

LimitValue
Code size300 KB hard limit per function (warning above 100 KB) โ€” ship a single bundled file, no node_modules at runtime
Execution timeWorkflow runs: seconds (they block a flow step). Scheduled runs: 60 seconds hard
Memory10 MB sandbox default
LanguageJavaScript/TypeScript syntax, script context โ€” no import/export, no require. The epilot SDK is built in as context.epilot; other libraries must be bundled into your handler file
Forbiddeneval(), the Function() constructor โ€” rejected at deploy time
EnvironmentNo filesystem, no environment variables, no Node.js APIs โ€” network via context.epilot and context.fetch only
IsolationOne run = one installation. The token, options and data access are always scoped to a single organization

Deploy-time validation enforces the contract: syntax is parsed, a handler declaration is required, size and security rules are checked โ€” epilot app validate runs the same checks locally before you deploy.