Every Microapp is built from a tool engine: a single pure function with a typed input schema. The React widget consumes the engine. The MCP server consumes the engine. The OpenAPI spec generates from the engine. The same function answers the same question whether a person clicked a button or an agent called a tool.
One file, one function, two surfaces. If the answer differs between the web and the API, the engine is wrong — not the surfaces.
The shape
Engines live at src/lib/tool-engines/<slug>.ts. One file per Microapp, named to match the URL slug. Each file exports a single object with five required fields:
src/lib/tool-engines/<slug>.ts
│
├── name (string) the tool's canonical id, kebab-case
├── description (string) one sentence, written for an agent to read
├── inputSchema (Zod) validation + types + parameter descriptions
├── handler (function) pure (input) => output
└── examples (array) optional, ≥1 worked example for the agent
│
├── React widget → imports { handler, inputSchema }
├── MCP server → imports { name, description, inputSchema, handler, examples }
└── OpenAPI build → generates from { name, description, inputSchema }A worked example
The percentage calculator. Three modes, structured output, an error path, a docstring an agent can actually read. Copy this shape when in doubt.
src/lib/tool-engines/percentage-calculator.ts
import { z } from "zod"; import { defineEngine } from "./_engine"; export const percentageCalculator = defineEngine({ name: "percentage-calculator", description: "Calculate percentages three ways: what's X% of Y, what % is X of Y, " + "and what's the % change from X to Y. Use mode='of' for the first form, " + "mode='ratio' for the second, mode='change' for the third.", inputSchema: z.object({ mode: z .enum(["of", "ratio", "change"]) .describe("Which percentage operation. 'of' = X% of Y, 'ratio' = X is what % of Y, 'change' = % change from X to Y."), a: z.number().describe("First number. Meaning depends on mode (see description)."), b: z.number().describe("Second number. Meaning depends on mode."), precision: z .number() .int() .min(0) .max(10) .optional() .describe("Decimal places in the result. Defaults to 2."), }), handler: ({ mode, a, b, precision = 2 }) => { if (mode === "of") { // "What is a% of b?" → a/100 * b return { result: round((a / 100) * b, precision), formula: `(${a } / 100) × ${b }`, explanation: `${a }% of ${b }`, }; } if (mode === "ratio") { if (b === 0) { throw new EngineError( "Cannot compute a ratio when the second number (b) is zero — division by zero. Did you mean mode='of' instead?", "DIVISION_BY_ZERO" ); } return { result: round((a / b) * 100, precision), formula: `(${a } / ${b }) × 100`, explanation: `${a } is what % of ${b }`, }; } // mode === "change" if (a === 0) { throw new EngineError( "Cannot compute % change from zero — the starting value (a) is undefined as a base.", "DIVISION_BY_ZERO" ); } return { result: round(((b - a) / a) * 100, precision), formula: `((${b } - ${a }) / ${a }) × 100`, explanation: `% change from ${a } to ${b }`, }; }, examples: [ { title: "15% of 200", input: { mode: "of", a: 15, b: 200 }, output: { result: 30, formula: "(15 / 100) × 200" }, }, { title: "40 is what % of 200", input: { mode: "ratio", a: 40, b: 200 }, output: { result: 20, formula: "(40 / 200) × 100" }, }, { title: "% change from 50 to 75", input: { mode: "change", a: 50, b: 75 }, output: { result: 50, formula: "((75 - 50) / 50) × 100" }, }, ], });
The widget at src/components/tools/PercentageCalculator.tsx imports the same handler:
src/components/tools/PercentageCalculator.tsx
import { percentageCalculator } from "../../lib/tool-engines/percentage-calculator"; // In the widget's submit handler: const result = percentageCalculator.handler({ mode, a, b }); setResult(result);
The MCP server loops over every engine and exposes them — no per-tool work:
agents/workers/mcp/src/index.ts (sketch)
const engines = import.meta.glob( "../../../src/lib/tool-engines/*.ts", { eager: true } ); // For each engine, register it as an MCP tool. // inputSchema (Zod) converts to JSON Schema; handler runs on call. for (const mod of Object.values(engines)) { const engine = (mod as any).default ?? Object.values(mod)[0]; mcp.registerTool({ name: engine.name, description: engine.description, inputSchema: zodToJsonSchema(engine.inputSchema), handler: async (input) => engine.handler(engine.inputSchema.parse(input)), }); }
Rules — what every engine must honor
These are the "definition of done" for a tool engine. CI will eventually enforce most of them; for now they're a checklist Bob (and any human contributor) walks through before merging.
- Pure function, no side effects.
The handler takes a typed input and returns a value. No DOM, no network, no
document, nolocalStorage. If you need to fetch something external (currency rates, time-zone data), the input must include it as a parameter — never fetch inside the engine. - Deterministic and idempotent.
Same input → same output, every call. Randomness only via an explicit
seedparameter (and document it as such). Agents replay failed calls — non-determinism breaks that loop. - Zod schema with
.describe()on every parameter.The description is the agent's documentation. Write it like you're explaining the parameter to a junior engineer who can't see the source. "
amount of the bill, in the same currency as tip_amount" beats "number". - Structured output, not prose.
Return an object with named fields like
result,formula,explanation. Numeric results carry their unit in a separate field. Currency carries the ISO code. Timestamps are ISO 8601. Don't return"15% of 200 is 30"as a single string — return a structured object withresult: 30and anexpressionfield carrying the human-readable form. - Errors that teach.
Throw
EngineError(message, code). The message is targeted at the agent: name the bad input, suggest the fix. "amount must be positive; got -5. Did you mean to pass amount=5?" beats "Validation failed". The code is a stable identifier the MCP layer can pass through. - At least one example.
Examples are first-class. The MCP server passes them to the agent on tool discovery, so the agent knows what a real call looks like before guessing. Add one per branch of the logic (the percentage calculator has three modes → three examples).
- The engine file is the only place the calculation lives.
If you find yourself writing the same percentage math inside
PercentageCalculator.tsxANDpercentage-calculator.ts, delete the duplicate in the widget. The widget calls the engine. Always.
Always
- Pure function, single export, kebab-case slug
- Zod schema with
.describe()on every param - Structured object output
EngineError(message, code)for failures- ≥1 example per logical branch
- The widget imports the engine
Never
fetch(),localStorage, or DOM in the handler- Hidden randomness (no seed param)
- Returning a single formatted string
- Throwing raw
Errorobjects - Duplicating the math in the React widget
- Multiple exports from one engine file
Migrating an existing widget
Most existing Microapps were built before this convention. They keep their calculation logic inside the React widget. Migration is a small, mechanical refactor and doesn't have to happen in a sprint — Bob's build skill picks it up when the widget gets touched.
The 4-step recipe
- Identify the pure core.
Inside the widget's submit handler (or wherever the calculation happens), find the function that takes plain inputs and returns plain outputs. Ignore the UI state, the form fields, the toast notifications — just the math.
- Extract it to
src/lib/tool-engines/<slug>.ts.Wrap with
defineEngine. Write the Zod schema with descriptions. Add 1–3 examples. The handler is the pure function you just extracted. - Replace the calculation in the widget with a call to the engine.
const result = myEngine.handler(input);. The widget shrinks; the engine carries the logic. - Verify with the existing test (or write one if there isn't one).
The widget's behavior should be unchanged. If you broke something, the engine extraction missed a branch. Re-read and fix.
Why this shape and not another
A few alternatives we considered and rejected:
- Logic inside React widgets, MCP server maintains its own copy. Two implementations of the same math. Drift guaranteed. Rejected.
- Centralized MCP server with a per-tool handler map. Adding a tool means updating two places (the widget AND the MCP map). The catalog is centralized — anti the "deep modules" principle from System Design. Rejected.
- Engine + UI in the same file. Mixes server-runnable code with React/DOM imports. Breaks tree-shaking on the MCP server. Rejected.
- Co-located
tool.tsbesideTool.tsxincomponents/tools/. Works, but spreads engine files across a UI directory and complicates theimport.meta.globpath on the MCP worker. Cleaner to keep engines in one dir.
The shape above — one file per tool in src/lib/tool-engines/, exporting a single engine object — is the cleanest version that satisfies all the constraints. Boring is the point.
The engine is the tool. The widget and the MCP server are clothes the engine wears in different rooms.