# CRM with CodeCall

> A help desk's CRM server with 47 tools, put behind the CodeCall plugin so a model searches for the tools it needs, reads their schemas, and answers a question that spans customers, tickets and accounts with one script.

Source: https://frontmcp.dev/examples/crm-with-codecall

A help desk's CRM covers more than tickets: customers and their contacts, accounts, orders, invoices, notes, agents, macros and articles, each with its own list, get, create, update and delete. This example is a CRM server with 47 tools, and it doesn't list them. It puts them behind [CodeCall](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall): the model is listed six tools of CodeCall's, uses them to search for the tools it needs and read their schemas, and answers a question that spans customers, tickets and accounts with one script that runs on the server. The nine destructive tools stay out of the script's reach.

**You will learn**
- What a model is listed instead of 47 tools, and why that helps
- How to write tools a model can find and use through CodeCall: the words it will search for, `examples` and output schemas
- How a question that needs eight tool calls becomes a search, a describe and one script
- How to keep destructive tools out of scripts, and what that doesn't protect
- How to test all of it, and how the Playground's sandbox differs from a server's

## The server

Here is the whole server. Open the **Call** tab: `codecall:search` has looked for tools for four short phrases, the ones a model would send to answer *"Which enterprise customers have an open, high-priority ticket that nobody has replied to in two days, and who manages their account?"*. It found `list_customers`, `list_tickets`, `list_accounts` and `get_agent` among the 38 tools CodeCall may use. Call `codecall:describe` with `{ "toolNames": ["list_tickets"] }` to read what the model reads next, and `codecall:invoke` with `{ "tool": "close_ticket", "input": { "id": "T-8" } }` to make one call without a script. Then open the **Tests** tab: with no model in the Playground, they play its part.

```ts help-desk.app.ts active
import { App } from "@frontmcp/sdk";
import { CodeCallPlugin } from "@frontmcp/plugin-codecall";
import { areaTools } from "./area.tools";
import { CrmStore, TicketStore } from "./stores";
import { crmTools } from "./crm.tools";

@App({
  id: "help-desk",
  name: "Help Desk CRM",
  providers: [CrmStore, TicketStore],
  tools: [...areaTools, ...crmTools],
  plugins: [
    CodeCallPlugin.init({
      mode: "codecall_only",
      includeTools: (tool) => !tool.annotations?.destructiveHint,
    }),
  ],
})
export class HelpDeskApp {}
```

```ts area.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { CrmStore, type Table } from "./stores";

type Area = {
  table: Table;
  singular: string;
  about: string;
  fields: Record<string, z.ZodType>;
  filterBy: string[];
  listExample: Record<string, unknown>;
  readOnly?: boolean;
};

const customerId = z.string().describe("The customer's id, like C-1");

const areas: Area[] = [
  {
    table: "customers",
    singular: "customer",
    about: "the companies we support, with their support plan",
    fields: {
      name: z.string().describe("The company's name"),
      plan: z.enum(["enterprise", "standard"]).describe("The support plan"),
    },
    filterBy: ["plan"],
    listExample: { plan: "enterprise" },
  },
  {
    table: "contacts",
    singular: "contact",
    about: "the people at each customer who write to us",
    fields: {
      customerId,
      name: z.string().describe("The person's name"),
      email: z.string().describe("Their email address"),
      role: z.string().describe("Their job, like admin or billing"),
    },
    filterBy: ["customerId"],
    listExample: { customerId: "C-1" },
  },
  {
    table: "accounts",
    singular: "account",
    about: "each customer's contract, with its account manager and renewal date",
    fields: {
      customerId,
      managerId: z.string().describe("The account manager's agent id, like A-1"),
      renewsOn: z.string().describe("The renewal date, like 2027-01-15"),
    },
    filterBy: ["customerId", "managerId"],
    listExample: { customerId: "C-1" },
  },
  {
    table: "agents",
    singular: "agent",
    about: "the people who work at the help desk: support agents and account managers",
    fields: {
      name: z.string().describe("The agent's name"),
      team: z.enum(["support", "account management"]).describe("The team they work in"),
    },
    filterBy: ["team"],
    listExample: { team: "support" },
    readOnly: true,
  },
  {
    table: "notes",
    singular: "note",
    about: "internal notes on a ticket, never shown to the customer",
    fields: {
      ticketId: z.string().describe("The ticket's id, like T-1"),
      authorId: z.string().describe("The agent who wrote it, like A-3"),
      text: z.string().describe("The note"),
    },
    filterBy: ["ticketId"],
    listExample: { ticketId: "T-1" },
  },
  {
    table: "orders",
    singular: "order",
    about: "what a customer bought, with the total and whether it was paid or refunded",
    fields: {
      customerId,
      total: z.number().describe("The total in US dollars"),
      status: z.enum(["paid", "refunded"]).describe("Whether the order was paid or refunded"),
    },
    filterBy: ["customerId", "status"],
    listExample: { customerId: "C-1" },
  },
  {
    table: "invoices",
    singular: "invoice",
    about: "what we billed each customer, with its status and due date",
    fields: {
      customerId,
      total: z.number().describe("The total in US dollars"),
      status: z.enum(["paid", "open", "overdue"]).describe("Whether the invoice was paid, is waiting, or is late"),
      dueOn: z.string().describe("The due date, like 2026-10-01"),
    },
    filterBy: ["customerId", "status"],
    listExample: { status: "overdue" },
  },
  {
    table: "macros",
    singular: "macro",
    about: "canned replies that agents paste into tickets, by topic",
    fields: {
      name: z.string().describe("The macro's name"),
      topic: z.string().describe("What it answers, like login or billing"),
      body: z.string().describe("The reply's text"),
    },
    filterBy: ["topic"],
    listExample: { topic: "login" },
  },
  {
    table: "articles",
    singular: "article",
    about: "the help center's articles, published or still drafts",
    fields: {
      title: z.string().describe("The article's title"),
      published: z.boolean().describe("Whether customers can read it"),
      body: z.string().describe("The article's text"),
    },
    filterBy: ["published"],
    listExample: { published: true },
  },
];

export const notFound = (singular: string, id: string) =>
  new PublicMcpError(`There's no ${singular} ${id}.`, "NOT_FOUND");

function toolsForArea({ table, singular, about, fields, filterBy, listExample, readOnly }: Area) {
  const idSchema = z.string().describe(`The ${singular}'s id`);
  const rowSchema = z.object({ id: idSchema, ...fields });
  const filters = Object.fromEntries(filterBy.map((field) => [field, fields[field].optional()]));

  @Tool({
    name: `list_${table}`,
    description: `List ${table}: ${about}. Filter by ${filterBy.join(", ")}.`,
    inputSchema: filters,
    outputSchema: { [table]: z.array(rowSchema) },
    annotations: { readOnlyHint: true },
    examples: [{ description: `List ${table}`, input: listExample }],
  })
  class ListRows extends ToolContext {
    async execute(filter: Record<string, unknown>) {
      return { [table]: this.get(CrmStore).list(table, filter) };
    }
  }

  @Tool({
    name: `get_${singular}`,
    description: `Get one ${singular} by its id, with every field.`,
    inputSchema: { id: idSchema },
    outputSchema: rowSchema.shape,
    annotations: { readOnlyHint: true },
  })
  class GetRow extends ToolContext {
    async execute({ id }: { id: string }) {
      return this.get(CrmStore).get(table, id) ?? this.fail(notFound(singular, id));
    }
  }

  if (readOnly) return [ListRows, GetRow];

  @Tool({
    name: `create_${singular}`,
    description: `Create one ${singular}. Returns the new ${singular} with its id.`,
    inputSchema: fields,
    outputSchema: rowSchema.shape,
  })
  class CreateRow extends ToolContext {
    async execute(newFields: Record<string, unknown>) {
      return this.get(CrmStore).create(table, newFields);
    }
  }

  @Tool({
    name: `update_${singular}`,
    description: `Update the fields of one ${singular}. Fields you leave out keep their value.`,
    inputSchema: { id: idSchema, ...z.object(fields).partial().shape },
    outputSchema: rowSchema.shape,
  })
  class UpdateRow extends ToolContext {
    async execute({ id, ...changes }: { id: string } & Record<string, unknown>) {
      return this.get(CrmStore).update(table, id, changes) ?? this.fail(notFound(singular, id));
    }
  }

  @Tool({
    name: `delete_${singular}`,
    description: `Delete one ${singular} by its id. This can't be undone.`,
    inputSchema: { id: idSchema },
    outputSchema: rowSchema.shape,
    annotations: { destructiveHint: true },
  })
  class DeleteRow extends ToolContext {
    async execute({ id }: { id: string }) {
      return this.get(CrmStore).remove(table, id) ?? this.fail(notFound(singular, id));
    }
  }

  return [ListRows, GetRow, CreateRow, UpdateRow, DeleteRow];
}

export const areaTools = areas.flatMap(toolsForArea);
```

```ts crm.tools.ts
import { PublicMcpError, Tool, ToolContext, z } from "@frontmcp/sdk";
import { notFound } from "./area.tools";
import { CrmStore, TicketStore, ticketSchema } from "./stores";

const ticketFilters = z.object({
  customerId: z.string().optional().describe("Only this customer's tickets, like C-1"),
  status: z.enum(["open", "pending", "closed"]).optional().describe("Only tickets with this status"),
  priority: z.enum(["low", "normal", "high"]).optional().describe("Only tickets with this priority"),
  assigneeId: z.string().optional().describe("Only tickets assigned to this agent, like A-3"),
});

@Tool({
  name: "list_tickets",
  description:
    "List support tickets. Each has its status, priority, assignee, when it was opened and when we last replied to the customer (null if we haven't). " +
    "Filter by customer, status, priority and assignee.",
  inputSchema: ticketFilters.shape,
  outputSchema: { tickets: z.array(ticketSchema) },
  annotations: { readOnlyHint: true },
  examples: [{ description: "Open tickets with high priority", input: { status: "open", priority: "high" } }],
})
class ListTickets extends ToolContext {
  async execute(filter: z.infer<typeof ticketFilters>) {
    return { tickets: this.get(TicketStore).list(filter) };
  }
}

@Tool({
  name: "get_ticket",
  description: "Get one support ticket by its id, with every field.",
  inputSchema: { id: z.string().describe("The ticket's id, like T-1") },
  outputSchema: ticketSchema.shape,
  annotations: { readOnlyHint: true },
})
class GetTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(TicketStore).get(id) ?? this.fail(notFound("ticket", id));
  }
}

@Tool({
  name: "assign_ticket",
  description: "Assign a support ticket to an agent.",
  inputSchema: {
    id: z.string().describe("The ticket's id, like T-1"),
    agentId: z.string().describe("The agent's id, like A-3"),
  },
  outputSchema: ticketSchema.shape,
})
class AssignTicket extends ToolContext {
  async execute({ id, agentId }: { id: string; agentId: string }) {
    if (!this.get(CrmStore).get("agents", agentId)) this.fail(notFound("agent", agentId));
    return this.get(TicketStore).update(id, { assigneeId: agentId }) ?? this.fail(notFound("ticket", id));
  }
}

@Tool({
  name: "close_ticket",
  description: "Close a support ticket. The customer can reopen it by replying.",
  inputSchema: { id: z.string().describe("The ticket's id, like T-1") },
  outputSchema: ticketSchema.shape,
})
class CloseTicket extends ToolContext {
  async execute({ id }: { id: string }) {
    return this.get(TicketStore).update(id, { status: "closed" }) ?? this.fail(notFound("ticket", id));
  }
}

@Tool({
  name: "refund_order",
  description: "Refund an order in full. The money goes back to the customer's card.",
  inputSchema: { id: z.string().describe("The order's id, like O-1") },
  annotations: { destructiveHint: true },
})
class RefundOrder extends ToolContext {
  async execute({ id }: { id: string }) {
    const store = this.get(CrmStore);
    const order = store.get("orders", id) ?? this.fail(notFound("order", id));
    if (order.status === "refunded") this.fail(new PublicMcpError(`Order ${id} is already refunded.`, "ALREADY_REFUNDED"));
    return store.update("orders", id, { status: "refunded" });
  }
}

export const crmTools = [ListTickets, GetTicket, AssignTicket, CloseTicket, RefundOrder];
```

```ts stores.ts
import { Provider, ProviderScope, z } from "@frontmcp/sdk";

export const ticketSchema = z.object({
  id: z.string().describe("The ticket's id, like T-1"),
  customerId: z.string().describe("The customer's id, like C-1"),
  title: z.string().describe("What the ticket is about"),
  status: z.enum(["open", "pending", "closed"]).describe("Whether it is open, waiting on the customer, or closed"),
  priority: z.enum(["low", "normal", "high"]).describe("How urgent it is"),
  assigneeId: z.string().nullable().describe("The agent working on it, like A-3, or null"),
  openedAt: z.string().describe("When it was opened, as an ISO date"),
  lastReplyAt: z.string().nullable().describe("When we last replied to the customer, as an ISO date, or null if we haven't"),
});
export type Ticket = z.infer<typeof ticketSchema>;
export type TicketFilter = Partial<Pick<Ticket, "customerId" | "status" | "priority" | "assigneeId">>;

const hoursAgo = (hours: number) => new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();

@Provider({ name: "TicketStore", scope: ProviderScope.GLOBAL })
export class TicketStore {
  private readonly tickets: Ticket[] = [
    { id: "T-1", customerId: "C-1", title: "Cannot log in to the dashboard", status: "open", priority: "high", assigneeId: "A-3", openedAt: hoursAgo(120), lastReplyAt: hoursAgo(70) },
    { id: "T-2", customerId: "C-2", title: "Invoice shows the wrong total", status: "open", priority: "high", assigneeId: "A-4", openedAt: hoursAgo(100), lastReplyAt: hoursAgo(90) },
    { id: "T-3", customerId: "C-1", title: "Dashboard is slow", status: "open", priority: "normal", assigneeId: "A-3", openedAt: hoursAgo(200), lastReplyAt: hoursAgo(100) },
    { id: "T-4", customerId: "C-4", title: "API returns error 500", status: "open", priority: "high", assigneeId: "A-4", openedAt: hoursAgo(30), lastReplyAt: hoursAgo(5) },
    { id: "T-5", customerId: "C-5", title: "Single sign-on loops back to the login page", status: "open", priority: "high", assigneeId: "A-3", openedAt: hoursAgo(80), lastReplyAt: hoursAgo(52) },
    { id: "T-6", customerId: "C-5", title: "Export to CSV", status: "open", priority: "low", assigneeId: "A-4", openedAt: hoursAgo(300), lastReplyAt: hoursAgo(200) },
    { id: "T-7", customerId: "C-1", title: "New API key", status: "closed", priority: "high", assigneeId: "A-3", openedAt: hoursAgo(400), lastReplyAt: hoursAgo(300) },
    { id: "T-8", customerId: "C-3", title: "Password reset email never arrives", status: "open", priority: "high", assigneeId: "A-4", openedAt: hoursAgo(40), lastReplyAt: hoursAgo(30) },
    { id: "T-9", customerId: "C-4", title: "Webhook retries", status: "pending", priority: "high", assigneeId: "A-3", openedAt: hoursAgo(150), lastReplyAt: hoursAgo(80) },
    { id: "T-10", customerId: "C-1", title: "Data export is missing rows", status: "open", priority: "high", assigneeId: "A-4", openedAt: hoursAgo(49), lastReplyAt: null },
  ];

  list(filter: TicketFilter) {
    return this.tickets.filter(
      (ticket) =>
        (!filter.customerId || ticket.customerId === filter.customerId) &&
        (!filter.status || ticket.status === filter.status) &&
        (!filter.priority || ticket.priority === filter.priority) &&
        (!filter.assigneeId || ticket.assigneeId === filter.assigneeId),
    );
  }

  get(id: string) {
    return this.tickets.find((ticket) => ticket.id === id);
  }

  update(id: string, changes: Partial<Pick<Ticket, "status" | "assigneeId">>) {
    const ticket = this.get(id);
    return ticket && Object.assign(ticket, changes);
  }
}

export type Table = "customers" | "contacts" | "accounts" | "agents" | "notes" | "orders" | "invoices" | "macros" | "articles";
type Row = { id: string } & Record<string, unknown>;

const idPrefixes: Record<Table, string> = {
  customers: "C",
  contacts: "P",
  accounts: "AC",
  agents: "A",
  notes: "N",
  orders: "O",
  invoices: "INV",
  macros: "M",
  articles: "KB",
};

@Provider({ name: "CrmStore", scope: ProviderScope.GLOBAL })
export class CrmStore {
  private readonly tables: Record<Table, Row[]> = {
    customers: [
      { id: "C-1", name: "Acme", plan: "enterprise" },
      { id: "C-2", name: "Globex", plan: "standard" },
      { id: "C-3", name: "Initech", plan: "standard" },
      { id: "C-4", name: "Umbrella", plan: "enterprise" },
      { id: "C-5", name: "Hooli", plan: "enterprise" },
    ],
    contacts: [
      { id: "P-1", customerId: "C-1", name: "Ada", email: "ada@acme.example", role: "admin" },
      { id: "P-2", customerId: "C-2", name: "Linus", email: "linus@globex.example", role: "billing" },
      { id: "P-3", customerId: "C-4", name: "Grace", email: "grace@umbrella.example", role: "admin" },
      { id: "P-4", customerId: "C-5", name: "Margaret", email: "margaret@hooli.example", role: "admin" },
    ],
    agents: [
      { id: "A-1", name: "Nour", team: "account management" },
      { id: "A-2", name: "Dana", team: "account management" },
      { id: "A-3", name: "Priya", team: "support" },
      { id: "A-4", name: "Sam", team: "support" },
    ],
    accounts: [
      { id: "AC-1", customerId: "C-1", managerId: "A-1", renewsOn: "2027-01-15" },
      { id: "AC-2", customerId: "C-2", managerId: "A-2", renewsOn: "2026-11-30" },
      { id: "AC-3", customerId: "C-3", managerId: "A-2", renewsOn: "2027-03-01" },
      { id: "AC-4", customerId: "C-4", managerId: "A-1", renewsOn: "2026-12-10" },
      { id: "AC-5", customerId: "C-5", managerId: "A-2", renewsOn: "2027-02-20" },
    ],
    notes: [
      { id: "N-1", ticketId: "T-1", authorId: "A-3", text: "Reproduced on Chrome. Asked Acme for a HAR file." },
      { id: "N-2", ticketId: "T-5", authorId: "A-3", text: "Hooli's identity provider returns a 302 to /login." },
    ],
    orders: [
      { id: "O-1", customerId: "C-1", total: 12000, status: "paid" },
      { id: "O-2", customerId: "C-2", total: 900, status: "paid" },
      { id: "O-3", customerId: "C-5", total: 12000, status: "refunded" },
    ],
    invoices: [
      { id: "INV-1", customerId: "C-2", total: 900, status: "overdue", dueOn: "2026-09-15" },
      { id: "INV-2", customerId: "C-1", total: 12000, status: "paid", dueOn: "2026-08-31" },
    ],
    macros: [{ id: "M-1", name: "Password reset steps", topic: "login", body: "Open Settings, choose Security, then Reset password." }],
    articles: [{ id: "KB-1", title: "Reset your password", published: true, body: "Open Settings, choose Security, then Reset password." }],
  };

  list(table: Table, filter: Record<string, unknown>) {
    return this.tables[table].filter((row) =>
      Object.entries(filter).every(([field, wanted]) => wanted === undefined || row[field] === wanted),
    );
  }

  get(table: Table, id: string) {
    return this.tables[table].find((row) => row.id === id);
  }

  create(table: Table, fields: Record<string, unknown>) {
    const rows = this.tables[table];
    const lastNumber = Math.max(0, ...rows.map((row) => Number(row.id.split("-")[1])));
    const row = { id: `${idPrefixes[table]}-${lastNumber + 1}`, ...fields };
    rows.push(row);
    return row;
  }

  update(table: Table, id: string, changes: Record<string, unknown>) {
    const row = this.get(table, id);
    return row && Object.assign(row, changes);
  }

  remove(table: Table, id: string) {
    const rows = this.tables[table];
    const index = rows.findIndex((row) => row.id === id);
    return index === -1 ? undefined : rows.splice(index, 1)[0];
  }
}
```

```ts main.ts
import "reflect-metadata";
import { FrontMcp } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";

@FrontMcp({
  info: { name: "Help Desk CRM", version: "1.0.0" },
  apps: [HelpDeskApp],
})
export default class HelpDeskServer {}
```

```ts crm.test.ts
import { test, expect } from "@frontmcp/testing";
import { App, connect, FrontMcpInstance } from "@frontmcp/sdk";
import { HelpDeskApp } from "./help-desk.app";
import { areaTools } from "./area.tools";
import { crmTools } from "./crm.tools";
import { CrmStore, TicketStore } from "./stores";

const searchPhrases = ["customers by plan", "tickets by status and priority", "accounts and their managers", "agent by id"];

const staleEnterpriseTicketsScript = `
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const { customers } = await callTool("list_customers", { plan: "enterprise" });
const report = [];
for (const customer of customers) {
  const { tickets } = await callTool("list_tickets", { customerId: customer.id, status: "open", priority: "high" });
  const waiting = tickets.filter((ticket) => (ticket.lastReplyAt ?? ticket.openedAt) < twoDaysAgo);
  if (waiting.length === 0) continue;
  const { accounts } = await callTool("list_accounts", { customerId: customer.id });
  const manager = await callTool("get_agent", { id: accounts[0].managerId });
  report.push({ customer: customer.name, tickets: waiting.map((ticket) => ticket.id), accountManager: manager.name });
}
return report;
`.trim();

const callAndParse = async (mcp: any, tool: string, input: object) => (await mcp.tools.call(tool, input)).json();

test("the model is listed CodeCall's six tools, not the CRM's 47", async ({ mcp }) => {
  const names = (await mcp.tools.list()).map((tool: { name: string }) => tool.name);
  expect(names).toEqual([
    "codecall:describe",
    "codecall:execute",
    "codecall:invoke",
    "codecall:search",
    "codecall:searchKnowledge",
    "codecall:searchSkills",
  ]);
});

test("listing all 47 tools would send over 40 KB, where CodeCall's six send under 25 KB", async ({ mcp }) => {
  @App({ id: "help-desk", name: "Help Desk CRM", providers: [CrmStore, TicketStore], tools: [...areaTools, ...crmTools] })
  class WithoutCodeCall {}

  const withoutCodeCall = await connect({ info: { name: "help-desk-crm", version: "1.0.0" }, apps: [WithoutCodeCall] });
  const everyTool = await withoutCodeCall.listTools(); // follows every page
  await withoutCodeCall.close();
  expect(everyTool).toHaveLength(47);
  expect(JSON.stringify(everyTool).length).toBeGreaterThan(40_000);
  expect(JSON.stringify(await mcp.tools.list()).length).toBeLessThan(25_000);
});

test("step 1: searching finds a tool for each part of the question", async ({ mcp }) => {
  const { tools, totalAvailableTools } = await callAndParse(mcp, "codecall:search", { queries: searchPhrases });
  const bestMatches = tools.slice(0, 4).map((tool: { name: string }) => tool.name);
  expect(bestMatches.sort()).toEqual(["get_agent", "list_accounts", "list_customers", "list_tickets"]);
  expect(totalAvailableTools).toBe(38);
});

test("step 2: describing gives each tool's input and output shape, and its own example first", async ({ mcp }) => {
  const { tools } = await callAndParse(mcp, "codecall:describe", { toolNames: ["list_tickets", "get_agent"] });
  const [listTickets, getAgent] = tools;
  expect(Object.keys(listTickets.inputSchema.properties)).toEqual(["customerId", "status", "priority", "assigneeId"]);
  expect(Object.keys(listTickets.outputSchema.properties.tickets.items.properties)).toContain("lastReplyAt");
  expect(listTickets.usageExamples[0].description).toBe("Open tickets with high priority");
  expect(getAgent.outputSchema.properties.name).toEqual({ type: "string", description: "The agent's name" });
});

test("step 3: the script passes AgentScript's checks", async ({ mcp }) => {
  const outcome = await callAndParse(mcp, "codecall:execute", { script: staleEnterpriseTicketsScript });
  expect(["syntax_error", "illegal_access"]).not.toContain(outcome.status);
});

test("step 3: the script answers the question in one call", async ({ mcp }) => {
  const outcome = await callAndParse(mcp, "codecall:execute", { script: staleEnterpriseTicketsScript });
  expect(outcome).toEqual({
    status: "ok",
    result: [
      { customer: "Acme", tickets: ["T-1", "T-10"], accountManager: "Nour" },
      { customer: "Hooli", tickets: ["T-5"], accountManager: "Dana" },
    ],
  });
});

test("one action needs no script: codecall:invoke closes a ticket", async ({ mcp }) => {
  const closed = await callAndParse(mcp, "codecall:invoke", { tool: "close_ticket", input: { id: "T-8" } });
  expect(closed).toMatchObject({ id: "T-8", status: "closed" });
});

test("the generated tools create, update and read back a record", async ({ mcp }) => {
  const created = await callAndParse(mcp, "codecall:invoke", {
    tool: "create_contact",
    input: { customerId: "C-5", name: "Alan", email: "alan@hooli.example", role: "billing" },
  });
  expect(created.id).toBe("P-5");
  await callAndParse(mcp, "codecall:invoke", { tool: "update_contact", input: { id: "P-5", role: "admin" } });
  const found = await callAndParse(mcp, "codecall:invoke", { tool: "get_contact", input: { id: "P-5" } });
  expect(found).toMatchObject({ name: "Alan", role: "admin" });
});

test("a tool's error reaches the model through codecall:invoke as a message it can act on", async ({ mcp }) => {
  const refused = await mcp.tools.call("codecall:invoke", { tool: "assign_ticket", input: { id: "T-9", agentId: "A-9" } });
  expect(refused).toBeError("NOT_FOUND");
  expect(refused).toHaveTextContent("There's no agent A-9.");
  const assigned = await callAndParse(mcp, "codecall:invoke", { tool: "assign_ticket", input: { id: "T-9", agentId: "A-4" } });
  expect(assigned.assigneeId).toBe("A-4");
});

test("destructive tools are out of reach, even of a client that knows their names", async ({ mcp }) => {
  const { tools, notFound } = await callAndParse(mcp, "codecall:describe", { toolNames: ["get_agent", "delete_customer", "refund_order"] });
  expect(tools.map((tool: { name: string }) => tool.name)).toEqual(["get_agent"]);
  expect(notFound).toEqual(["delete_customer", "refund_order"]);
  const invoked = await mcp.tools.call("codecall:invoke", { tool: "refund_order", input: { id: "O-2" } });
  expect(invoked).toHaveTextContent('Tool "refund_order" is not available.');
  const direct = await mcp.tools.call("refund_order", { id: "O-2" });
  expect(direct).toBeError("TOOL_NOT_FOUND");
  expect(direct).toHaveTextContent('Tool "refund_order" not found');
  expect(await mcp.tools.call("get_ticket", { id: "T-1" })).toBeError("TOOL_NOT_FOUND"); // a read, too
});

test("a script can't call a destructive tool", async ({ mcp }) => {
  const outcome = await callAndParse(mcp, "codecall:execute", { script: "return await callTool('delete_customer', { id: 'C-1' });" });
  expect(outcome.status).toBe("tool_error");
  expect(outcome.error).toMatchObject({ toolName: "delete_customer", code: "ACCESS_DENIED" });
  const customer = await callAndParse(mcp, "codecall:invoke", { tool: "get_customer", input: { id: "C-1" } });
  expect(customer.name).toBe("Acme");
});

test("a script that reads first is stopped by the sandbox's pattern check, before the access check", async ({ mcp }) => {
  const script = "const customer = await callTool('get_customer', { id: 'C-1' });\nreturn await callTool('delete_customer', { id: customer.id });";
  const outcome = await callAndParse(mcp, "codecall:execute", { script });
  expect(outcome.status).toBe("runtime_error");
  expect(outcome.error.message).toContain("[DELETE_AFTER_ACCESS]");
});

test("a signed-in caller who repeats codecall:describe gets the first answer back from the cache", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk-crm", version: "1.0.0" }, apps: [HelpDeskApp] });
  const asAgent = { authContext: { user: { sub: "agent" } } };
  const first = await server.callTool("codecall:describe", { toolNames: ["get_agent"] }, asAgent);
  const second = await server.callTool("codecall:describe", { toolNames: ["get_agent"] }, asAgent);
  await server.dispose();
  expect(first._meta?.cache).toBeUndefined();
  expect(second._meta?.cache).toBe("hit");
});

test("codecall:invoke is never cached: the tool is called every time", async () => {
  const server = await FrontMcpInstance.createDirect({ info: { name: "help-desk-crm", version: "1.0.0" }, apps: [HelpDeskApp] });
  const asAgent = { authContext: { user: { sub: "agent" } } };
  const call = { tool: "get_agent", input: { id: "A-1" } };
  await server.callTool("codecall:invoke", call, asAgent);
  const second = await server.callTool("codecall:invoke", call, asAgent);
  await server.dispose();
  expect(second._meta?.cache).toBeUndefined();
});
```

`codecall:execute` runs scripts in the Playground too, with a different sandbox than a server's. A server runs a script in a sandbox built on Node's `vm` module, which a browser doesn't have, so the Playground runs it in [Enclave's browser sandbox](https://frontmcp.dev/reference/plugins/codecall#in-the-playground): two nested iframes with no network and no `eval`. The script is checked and rewritten by the same code first, its tool calls go through the same flow to the tools above, and the 14 tests here pass on Node and in the browser. [In the Playground](https://frontmcp.dev/reference/plugins/codecall#in-the-playground) lists what differs.

## How it fits together

*[Illustration: The CRM with CodeCall example. The client's model is listed six tools. It calls codecall:search with four phrases and CodeCall answers with four tools, then calls codecall:describe for those four and gets their schemas and examples, then calls codecall:execute with a script. CodeCall checks the script against AgentScript's rules and runs it in a sandbox. The script calls list_customers with the enterprise plan and gets Acme, Umbrella and Hooli, then for each of them calls list_tickets, and for the customers with a stale ticket list_accounts and get_agent. Calls to a delete tool or refund_order are refused with Access denied. Only the result, Acme and Hooli with their tickets and account managers, goes back to the model.]*
1. The model is listed CodeCall's six tools, not 47. Their descriptions tell it how to work: search, then describe, then execute or invoke.
2. It calls `codecall:search` with a short phrase for each thing it needs. CodeCall ranks the CRM's tools by the words they share with each phrase, and answers with the best ones.
3. It calls `codecall:describe` with the names it picked, and gets each tool's input and output schemas, and usage examples.
4. It writes a script from what it read, and calls `codecall:execute`. CodeCall checks the script against AgentScript's rules, then runs it in a sandbox on the server.
5. The script calls the CRM's tools with `callTool()`: eight calls here, each through the same flow as a call from a client.
6. Only what the script returns goes back to the model.

This is the script the tests send, as a model would write it, once it has read the schemas:

```js
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const { customers } = await callTool("list_customers", { plan: "enterprise" });
const report = [];
for (const customer of customers) {
  const { tickets } = await callTool("list_tickets", { customerId: customer.id, status: "open", priority: "high" });
  const waiting = tickets.filter((ticket) => (ticket.lastReplyAt ?? ticket.openedAt) < twoDaysAgo);
  if (waiting.length === 0) continue;
  const { accounts } = await callTool("list_accounts", { customerId: customer.id });
  const manager = await callTool("get_agent", { id: accounts[0].managerId });
  report.push({ customer: customer.name, tickets: waiting.map((ticket) => ticket.id), accountManager: manager.name });
}
return report;
```

Here is the server again, with `codecall:execute` already called with that script. Its **Call** tab has the script in its `script` field, so the answer is on screen when it starts, and you can change the script and send it again. Try `plan: "standard"` in the first `callTool`, which answers Globex with T-2 and its manager Dana, or `status: "pending"`, which answers Umbrella with T-9 and Nour. **Show code** opens the server's files, the same as above.

`codecall:execute` answers:

```json
{
  "status": "ok",
  "result": [
    { "customer": "Acme", "tickets": ["T-1", "T-10"], "accountManager": "Nour" },
    { "customer": "Hooli", "tickets": ["T-5"], "accountManager": "Dana" }
  ]
}
```

The model reads three lines instead of eight results. Acme has two stale tickets, and T-10 was never answered, so the script counts from when it was opened. Umbrella's only open high-priority ticket was answered five hours ago, and Globex's stale one belongs to a standard customer.

Why put a 47-tool server behind CodeCall? Listed in full, the tools come to about 43 KB of names, descriptions and schemas, sent with every request. CodeCall's six come to about 21 KB, and stay that size as the CRM grows. That's a saving, but not the biggest one. Nine areas with the same five verbs are look-alike tools, and the question above needs eight calls in a row, each result passing through the model. As a script, it's one call, and the model never sees the tickets it filtered out.

## The files

### `stores.ts`: two stores

`TicketStore` holds the help desk's own data: ten tickets, typed with `ticketSchema`, which the ticket tools reuse as their `outputSchema`. Their times count back from when the server starts, so the two-day rule holds whenever you run the example: T-1 was last answered 70 hours ago, T-4 five hours ago, T-10 never (`lastReplyAt` is `null`). The tickets are chosen so that every part of the question matters: T-2 is stale and urgent but belongs to a standard customer, T-3 is stale but not urgent, T-7 and T-9 are urgent but closed or waiting on the customer.

`CrmStore` stands in for the CRM's database: nine tables of rows, with `list`, `get`, `create`, `update` and `remove` that work on any of them. Both are [providers](https://frontmcp.dev/reference/sdk/provider), `GLOBAL`, so every tool gets the same instance. A real server would read your databases here.

### `area.tools.ts`: 42 tools from nine areas

The nine areas are a list of settings, and `toolsForArea` writes their tools: `list_`, `get_`, `create_`, `update_` and `delete_`, with only `list_` and `get_` for the agents, who are read-only. It's the loop the lesson uses to write its 35 tools; a real CRM server would have a file for each area. What matters is what each tool tells the model:

```ts area.tools.ts
@Tool({
  name: `list_${table}`,
  description: `List ${table}: ${about}. Filter by ${filterBy.join(", ")}.`,
  inputSchema: filters,
  outputSchema: { [table]: z.array(rowSchema) },
  annotations: { readOnlyHint: true },
  examples: [{ description: `List ${table}`, input: listExample }],
})
```

- **The description** says what the area holds in the words a model would search for. Search matches words, not meaning, so `about` says "each customer's contract, with its account manager and renewal date", and `accounts and their managers` finds `list_accounts`. See [Finding tools: search, then describe](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall#finding-tools-search-then-describe).
- **`filterBy`** keeps each list tool's input to the fields worth filtering by, and every field has a `.describe()`, which `codecall:describe` passes on.
- **The `examples`** on every list tool matter more than they look. `codecall:describe` shows a tool's own examples and only those. A tool without any gets one CodeCall writes from its name and schema, and for a list tool that ends `return result.items || result`: a guess at how the answer is shaped, and these tools don't return `items`. A model copies the example it sees, so it should be yours. The step 2 test checks that it is.
- **The `outputSchema`** matters most for scripts. `codecall:describe` returns it, and it's how the model learns that `list_customers` answers `{ customers: [...] }` and that a ticket's `lastReplyAt` can be `null`. Without one, `outputSchema` is `null` and the model writes its script blind.
- **The annotations** are what the filter in `help-desk.app.ts` reads: `readOnlyHint` on the reads, and `destructiveHint` on `delete_…`.

### `crm.tools.ts`: the tools that aren't create, read, update and delete

Tickets have a workflow of their own, and their own typed store, so their tools are written by hand: `list_tickets`, `get_ticket`, `assign_ticket` and `close_ticket`. `refund_order` is the ninth destructive tool, next to the eight `delete_…` tools.

```ts crm.tools.ts
if (!this.get(CrmStore).get("agents", agentId)) this.fail(notFound("agent", agentId));
```

`notFound` is a [`PublicMcpError`](https://frontmcp.dev/reference/sdk/tool#returning-an-error-the-model-can-read) with a code and a message written for the model: `There's no agent A-9.` `codecall:invoke` returns it as it is, as a test checks, so the model can correct the id. `assign_ticket` checks the agent before it changes the ticket, so a wrong id leaves the ticket as it was.

### `help-desk.app.ts` and `main.ts`: CodeCall on the app

```ts help-desk.app.ts
CodeCallPlugin.init({
  mode: "codecall_only",
  includeTools: (tool) => !tool.annotations?.destructiveHint,
}),
```

`"codecall_only"` lists CodeCall's six tools instead of the CRM's. `includeTools` is called for every tool, with its annotations, and CodeCall only uses the tools it returns `true` for. The eight `delete_…` tools and `refund_order` are marked `destructiveHint`, so 38 of the 47 remain, which is what `totalAvailableTools` counts. A destructive tool added later is kept out too, without touching this file. Marking them with `codecall: { enabledInCodeCall: false }` one by one would miss the next one. See [Keeping tools away from CodeCall](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall#keeping-tools-away-from-codecall).

A tool kept out isn't found by search, is in `notFound` for `codecall:describe`, is refused by `codecall:invoke`, and gets `Access denied` in a script. The model can't tell whether it exists.

`main.ts` is the usual `@FrontMcp` server. It makes no difference whether CodeCall is on the app or on the server: either way it hides every app's tools from `tools/list` and searches all of them ([Where to register it](https://frontmcp.dev/reference/plugins/codecall#codecallplugininitoptions)). The stores are the app's `providers`, which is enough for tools; jobs and agents need theirs on the server.

### `crm.test.ts`

The tests play the model. Steps 1 to 3 are what it does with the question: search with four phrases, describe what it found, run one script. The others check what surrounds them.

- The first two tests are the case for CodeCall: the client is listed six tools, and a second copy of the server without the plugin lists all 47 at over 40 KB. Past 40 tools, `tools/list` comes in pages ([Paging long tool lists](https://frontmcp.dev/reference/sdk/frontmcp#paging-long-tool-lists)). The client `connect()` returns follows every page, so its `listTools()` gives all 47. `listTools()` on the server `create()` returns still gives only the first 40, with a `nextCursor`, so the copy is made with `connect()`.
- Step 3 has two tests: one that the script passes CodeCall's checks, and one that it answers the question. The second runs the script, in Node's `vm` sandbox on Node and in Enclave's browser sandbox in the Playground, and both tests pass in both.
- `codecall:invoke` needs no script for one action, and it returns a tool's own error message, which a script doesn't get ([Handling a tool's failure in a script](https://frontmcp.dev/reference/plugins/codecall#handling-a-tools-failure-in-a-script)).
- The destructive tools are out of reach: not found, not described, not invoked, and refused in a script. A client that sends `tools/call` for `refund_order` by name gets `Tool "refund_order" not found`, which is the last assertion of that test: in `codecall_only`, FrontMCP answers a direct call of a tool CodeCall hides as if it didn't exist, and that's every CRM tool. A script that reads first and then calls one, `get_customer` and then `delete_customer`, never reaches the refusal: the sandbox's pattern check stops it with `DELETE_AFTER_ACCESS` ([the limits](https://frontmcp.dev/reference/plugins/codecall#limits) list the patterns), and the last script test shows it.
- The last two tests call as a signed-in caller with `FrontMcpInstance.createDirect()`. CodeCall caches `codecall:search` and `codecall:describe` for 60 seconds per signed-in caller: the second identical call comes back with `_meta.cache: "hit"`. `codecall:invoke` and `codecall:execute` are never cached. Anonymous callers, like the Playground's, are never cached either, which is why no other test sees old answers.

All the tests share one server, in order. The ticket tests change T-8 and T-9 and the generated-tools test adds a contact, and none of them touches what the script reads. [Testing Your Server](https://frontmcp.dev/learn/testing-your-server) covers the test API.

## Running it for real

Nothing in the code changes. Install the plugin and the Cache plugin it needs, on Node 24 or later:

```bash
npm install @frontmcp/plugin-codecall @frontmcp/plugin-cache
```

I ran the server from this page in a project made with `frontmcp create`, with FrontMCP 1.9.1 and `@frontmcp/plugin-codecall` 1.9.1, started it with `frontmcp dev`, and called it over HTTP. `tools/list` answered CodeCall's six tools, with no page to follow, and `codecall:execute` ran the script:

```bash
curl -s http://127.0.0.1:3000/mcp \
  -H 'content-type: application/json' \
  -H 'mcp-protocol-version: 2026-07-28' \
  -H 'mcp-method: tools/call' \
  -H 'mcp-name: codecall:execute' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"codecall:execute","arguments":{"script":"…"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
```

With the script above as the string, its `structuredContent` was the result shown earlier. With FrontMCP 1.9.4 the tests on this page pass unchanged, in Node; I didn't repeat the run over HTTP. What I couldn't check is a real model: the Playground has none, and neither did I. Whether it searches with phrases that find these tools, describes before it runs, and writes a script AgentScript accepts is for your first conversations to show. A script it gets wrong isn't lost: `illegal_access` and `syntax_error` name the rule and the line, and nothing in the script has run. [What a script may do](https://frontmcp.dev/learn/letting-the-model-write-code-with-codecall#what-a-script-may-do) lists the rules.

Some things behave differently in production:

- **Signed-in callers see cached answers.** Edit a tool's description, or add a tool, and a signed-in caller who searched or described in the last minute gets the old answer until it expires. The last two tests in this example show the cache.
- **No tool but CodeCall's can be called directly.** A client that sends `tools/call` for `refund_order`, or for `get_ticket`, gets `Tool "…" not found`, over HTTP too. A widget, or a client of `createDirect()`, that should call a tool directly needs `codecall: { visibleInListTools: true }` on it. To decide who may call a tool through CodeCall, use [`authorities`](https://frontmcp.dev/learn/authorizing-calls#declaring-who-may-call-a-tool), which apply to CodeCall's calls as to any other. (Before FrontMCP 1.9, a client that knew a hidden tool's name could call it.)
- **Scripts run with the caller's credentials**, within CodeCall's limits: 3.5 seconds of computing time, 5,000 tool calls. [The limits](https://frontmcp.dev/reference/plugins/codecall#limits) lists them, and the patterns the sandbox stops, like a `delete_…` call soon after a read.
- **Tests.** In the scratch project, `npx frontmcp test` ran all fourteen, and they passed, with the Jest 30 that `frontmcp create` installs and nothing added to `frontmcp.config.ts`: `frontmcp test` transpiles the two ES modules CodeCall's Cache plugin loads, `@noble/hashes` and `@noble/ciphers`, by default. The spec needed `test.use({ server: "./src/main.ts" })` ([fixtures](https://frontmcp.dev/reference/testing/fixtures)) to start the server. Three tests, the size comparison and the two cache tests, start a second server inside the test process. (With FrontMCP 1.8.7 those three failed there with `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG`, unless `NODE_OPTIONS=--experimental-vm-modules` was set.)

## Ideas to try

Each of these is a change to the Playground above. Add a test for each.

1. Rename `list_tickets` to `search_tickets` and remove its `examples`. Look at what `codecall:describe` writes for it now: a `query` the tool doesn't have, and every ticket back if a model copies it. Put the example back, and test that yours is first.
2. Make the question ask for more: "...and who has an overdue invoice". Give Hooli an overdue invoice, call `list_invoices` for each customer in the script, and add the invoices to what it returns, and check the result.
3. Make the CRM read-only for the model: change `includeTools` so that only tools with `readOnlyHint` remain, and check that `close_ticket` is refused by `codecall:invoke` and in a script.
4. Let a client call one tool directly: give `get_agent` `codecall: { visibleInListTools: true }`, test that it's listed next to CodeCall's six and that a direct `tools/call` for it works, and that `refund_order` is still `not found`.
