Skip to main content
Preview — Jelou Functions is in preview. The API and behavior may change without prior notice.

When to use app() vs define()?

PatternWhen to use it
define()A single operation with one HTTP endpoint
app()Multiple related tools you want to deploy together
If your project has a single handler, use define(). If you need to group multiple operations — for example, sending and reading emails from the same service — use app().

Basic example

index.ts
import { app, define, z } from "@jelou/functions";

export default app({
  config: { cors: { origin: "*" }, timeout: 30_000 },
  tools: {
    sendEmail: define({
      description: "Sends an email",
      input: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
      config: { timeout: 5_000 },
      handler: async (input) => ({ sent: true }),
    }),
    readInbox: define({
      description: "Reads inbox messages",
      input: z.object({ limit: z.coerce.number().default(10) }),
      handler: async (input) => ({ messages: [] }),
    }),
  },
});

Local testing

Start the server with jelou dev and test each tool at its route:
curl -X POST http://localhost:3000/send-email \
  -H "Content-Type: application/json" \
  -d '{"to": "[email protected]", "subject": "Hello", "body": "Welcome"}'
curl http://localhost:3000/read-inbox?limit=5
If you send an invalid field, you receive a 400 with details:
curl -X POST http://localhost:3000/send-email \
  -H "Content-Type: application/json" \
  -d '{"to": ""}'

API

import { app } from "@jelou/functions";

const edgeApp = app({
  config: { cors: { origin: "*" }, timeout: 30_000 },
  tools: {
    /* ... your define() here ... */
  },
});

export default edgeApp;
Type signature:
function app(options: {
  config?: AppConfig;
  tools: Record<string, AnyEdgeFunction>;
}): EdgeApp;

AppConfig

interface AppConfig {
  cors?: {
    origin?: string | string[];
    methods?: string[];
    headers?: string[];
    credentials?: boolean;
    maxAge?: number;
  };
  timeout?: number;
  methods?: string[];
  mcp?: boolean;
}
FieldTypeDefaultDescription
corsobject{ origin: "*" }Global CORS configuration
timeoutnumber30000Timeout in ms for all tools
methodsstring[]["GET","POST","PUT","PATCH","DELETE"]Allowed HTTP methods
mcpbooleantrueRegister tools in the MCP server

Auto-generated routes

Your tools object keys are automatically converted to kebab-case to generate HTTP routes:
KeyRoute
sendEmail/send-email
readInbox/read-inbox
MyTool/my-tool
To customize a route, use config.path in the individual define():
tools: {
  sendEmail: define({
    config: { path: "/emails/send" },
    handler: async (input) => ({ sent: true }),
  }),
}

Config combination

The global config of app() applies to all tools. Each define() can override specific values:
FieldBehavior
timeoutTool overrides the global
methodsTool overrides the global
corsTool overrides the global
mcpTool overrides the global
pathAlways per tool
cronAlways per tool
export default app({
  config: { timeout: 30_000, cors: { origin: "*" } },
  tools: {
    fast: define({
      config: { timeout: 5_000 },
      handler: async () => ({ ok: true }),
    }),
    slow: define({
      handler: async () => ({ ok: true }),
    }),
  },
});
In this example, fast has a 5-second timeout and slow inherits the global 30 seconds. Both share the CORS configuration.

Health endpoint

In app mode, /__health returns information about all tools:
{
  "mode": "app",
  "tools": [
    {
      "key": "sendEmail",
      "name": "Send Email",
      "description": "Sends an email",
      "path": "/send-email",
      "cron": []
    },
    {
      "key": "readInbox",
      "name": "Read Inbox",
      "description": "Reads inbox messages",
      "path": "/read-inbox",
      "cron": []
    }
  ]
}

Type guards

Use isEdgeApp() and isEdgeFunction() to identify the export type at runtime:
import { isEdgeApp, isEdgeFunction } from "@jelou/functions";

isEdgeApp(module);      // true if created with app()
isEdgeFunction(module);  // true if created with define()

Multi-tool cron

Each tool inside app() can have its own independent cron schedules. Cron requests are sent to the specific route of each tool.
export default app({
  tools: {
    dailyCleanup: define({
      description: "Cleans up stale records",
      input: z.object({}),
      config: { cron: [{ expression: "0 3 * * *", timezone: "UTC" }] },
      handler: async (_input, ctx) => {
        if (!ctx.isCron) return { skipped: true };
        return { cleaned: true };
      },
    }),
    hourlySync: define({
      description: "Syncs external data",
      input: z.object({}),
      config: { cron: [{ expression: "0 * * * *" }] },
      handler: async (_input, ctx) => {
        if (!ctx.isCron) return { skipped: true };
        return { synced: true };
      },
    }),
  },
});
The 10 cron schedule limit is aggregated across all tools in an app(). If dailyCleanup has 3 and hourlySync has 4, only 3 remain available.

Multi-tool MCP

A single MCP server at /mcp automatically registers all tools from the app(). To exclude a tool from the MCP registry, use mcp: false in its config:
export default app({
  tools: {
    publicTool: define({
      description: "Visible to MCP",
      input: z.object({}),
      handler: async () => ({}),
    }),
    internalTool: define({
      description: "HTTP only",
      input: z.object({}),
      config: { mcp: false },
      handler: async () => ({}),
    }),
  },
});
In this example, AI agents only discover publicTool via MCP. internalTool is only accessible via direct HTTP at its /internal-tool route.

Exposed routes (app mode)

RouteDescription
/__healthHealth check with tool list
/mcpUnified MCP server (unless config.mcp: false globally)
/<tool-key>Each tool’s route (kebab-case or custom config.path)

Common issues

tools keys are converted to kebab-case. If your key is sendEmail, the route is /send-email, not /sendEmail.Verify the actual routes with the health check:
curl http://localhost:3000/__health

Next steps