Skip to main content
With app() you group multiple related operations in a single deployment. Your AI agents discover each tool individually via MCP, and each has its own HTTP route. Pattern: app() + multiple define() + .describe() on each field + shared config + ctx.env.get() for secrets.
index.ts
import { app, define, z } from "@jelou/functions";

export default app({
  config: { cors: { origin: "*" }, timeout: 15_000 },
  tools: {
    createContact: define({
      description: "Creates a new contact with name, email, and optional phone",
      input: z.object({
        name: z.string().min(1).describe("Contact's full name"),
        email: z.string().email().describe("Email address"),
        phone: z.string().optional().describe("Phone with country code, e.g.: 593987654321"),
      }),
      output: z.object({
        id: z.string(),
        created: z.boolean(),
      }),
      handler: async (input, ctx) => {
        ctx.log("Creating contact", { email: input.email });
        const apiKey = ctx.env.get("CRM_API_KEY");

        const res = await fetch("https://crm.example.com/api/contacts", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            Authorization: `Bearer ${apiKey}`,
          },
          body: JSON.stringify(input),
        });

        const data = await res.json();
        return { id: data.id, created: true };
      },
    }),

    searchContacts: define({
      description: "Searches contacts by name or email. Returns up to 20 results.",
      input: z.object({
        q: z.string().min(1).describe("Search term: contact name or email"),
        limit: z.coerce.number().default(10).describe("Maximum number of results (1-20)"),
      }),
      output: z.object({
        contacts: z.array(z.object({
          id: z.string(),
          name: z.string(),
          email: z.string(),
        })),
        total: z.number(),
      }),
      config: { methods: ["GET"] },
      handler: async (input, ctx) => {
        ctx.log("Searching contacts", { q: input.q });
        const apiKey = ctx.env.get("CRM_API_KEY");

        const params = new URLSearchParams({
          q: input.q,
          limit: String(input.limit),
        });

        const res = await fetch(
          `https://crm.example.com/api/contacts?${params}`,
          { headers: { Authorization: `Bearer ${apiKey}` } },
        );

        const data = await res.json();
        return { contacts: data.items, total: data.total };
      },
    }),

    deleteContact: define({
      description: "Deletes a contact by its ID. Returns deletion confirmation.",
      input: z.object({
        contactId: z.string().min(1).describe("Unique ID of the contact to delete"),
      }),
      output: z.object({
        deleted: z.boolean(),
      }),
      handler: async (input, ctx) => {
        ctx.log("Deleting contact", { id: input.contactId });
        const apiKey = ctx.env.get("CRM_API_KEY");

        const res = await fetch(
          `https://crm.example.com/api/contacts/${input.contactId}`,
          {
            method: "DELETE",
            headers: { Authorization: `Bearer ${apiKey}` },
          },
        );

        if (!res.ok) {
          return { deleted: false };
        }
        return { deleted: true };
      },
    }),
  },
});

Local testing

Start the server with jelou dev and test each tool:
curl -X POST http://localhost:3000/create-contact \
  -H "Content-Type: application/json" \
  -d '{"name": "Maria Garcia", "email": "[email protected]", "phone": "593987654321"}'
curl "http://localhost:3000/search-contacts?q=maria&limit=5"
curl -X POST http://localhost:3000/delete-contact \
  -H "Content-Type: application/json" \
  -d '{"contactId": "ct_a1b2c3"}'
If you send an invalid input, you receive a 400 with the error details:
curl -X POST http://localhost:3000/create-contact \
  -H "Content-Type: application/json" \
  -d '{"name": "", "email": "not-an-email"}'

Why it works this way

  • Automatic routes — the keys createContact, searchContacts, deleteContact generate /create-contact, /search-contacts, /delete-contact.
  • GET vs POSTsearchContacts uses config: { methods: ["GET"] } to receive parameters as query strings. The others use POST by default.
  • description matters — each tool has a specific description that tells the AI agent exactly what it does and what it returns. “Searches contacts by name or email” is much better than “Searches contacts”.
  • Shared configcors and timeout are defined once in app() and apply to all tools. Each tool can override them if needed.
  • Centralized secrets — all 3 tools use ctx.env.get("CRM_API_KEY"). You configure the secret once with jelou secrets set.
  • Unified MCPcurl http://localhost:3000/mcp returns the 3 tools as independent tools the agent can invoke.