Bot Code Organization and Build Setup | Medplum

The Single-File Constraint

Medplum deploys one JavaScript file per bot. This is by design: it keeps the deployment unit simple and portable across runtimes (Lambda, VM context). That file must contain your shared src/ modules and any third-party libraries you depend on, except packages you mark as external because they are already supplied by the Medplum bot runtime (for AWS Lambda, the bot Lambda layer).

For a single bot this is fine. For a project with many bots sharing common logic, you need a way to write modular source code while still producing a single file per bot at deploy time. The answer is a bundler.

Project Structure

Organize your source into two directories:

my-bots/

├── src/

│   ├── bots/               # One file per bot

│   │   ├── send-order.ts

│   │   ├── receive-result.ts

│   │   └── sync-patient.ts

│   └── common/             # Shared utilities imported by bots

│       ├── connection.ts

│       ├── order-utils.ts

│       └── constants.ts

├── dist/

│   └── bots/               # Compiled output — one file per bot

│       ├── send-order.js

│       ├── receive-result.js

│       └── sync-patient.js

├── esbuild.mjs

└── package.json

Each file in src/bots/ exports a single handler function. Each file in src/common/ is a plain TypeScript module — no bot-specific logic.

Bundling with esbuild

esbuild is a fast JavaScript bundler that compiles each bot into one deployable file, inlining your ../common/ imports and (by default) npm packages from node_modules. Packages listed in external are left as require() calls — on AWS Lambda, Medplum resolves those from the bot Lambda layer, so list every layer-provided package you import to avoid bloating the bundle or duplicating runtime libraries. The layer’s package catalog is defined in Medplum’s packages/bot-layer/package.json. Other bundlers work too, as long as they support inlining local imports and marking those imports as external.

Installation

npm install --save-dev esbuild glob

esbuild.mjs

import esbuild from 'esbuild';

import { glob } from 'glob';

// Compile every .ts file in src/bots/ (excluding tests)

const botEntryPoints = glob.sync('./src/bots/*.ts').filter((f) => !f.endsWith('.test.ts'));

esbuild

.build({

entryPoints: botEntryPoints,

outdir: './dist/bots',

bundle: true,       // Inline local imports and npm deps not listed in external

platform: 'node',

format: 'cjs',

loader: { '.ts': 'ts' },

target: 'es2020',

external: [

// Every Medplum layer package you import should appear here (expand as needed).

// Catalog: https://github.com/medplum/medplum/blob/main/packages/bot-layer/package.json

'@medplum/core',

'@medplum/definitions',

],

footer: { js: 'Object.assign(exports, module.exports);' },

})

.then(() => console.log('Build complete'))

.catch(() => process.exit(1));

Add a build script to package.json:

{

"scripts": {

"build": "node esbuild.mjs"

}

}

Run with:

npm run build

What the bundler does

Source After bundling
import { connectToApi } from '../common/connection' Inlined into the bot's .js file
import { MedplumClient } from '@medplum/core' Left as require('@medplum/core') when listed in external — resolved at runtime from the layer
import { RRule } from 'rrule' (after npm install rrule) Inlined into the bot's .js file — not on the layer, so do not add rrule to external

Each file under dist/bots/ contains every non-external dependency (your shared modules and bundled npm libraries). Your src/common/ folder is never deployed separately — its code is merged into each bot that imports it. Layer packages stay as require() calls.

Third-party npm dependencies

To ship a one-off library (for example rrule, lodash, or a small HTTP helper):

  1. npm install <package> (dependency or devDependency according to your repo conventions).
  2. import it from a bot under src/bots/ or from src/common/ as usual.
  3. Omit it from external unless that exact package is already provided by the Medplum bot Lambda layer (see the dependencies in the same bot-layer``package.json linked in the esbuild example). If it is not on the layer, esbuild bundles it into the compiled .js file.

Writing Bots and Shared Modules

Bot files (src/bots/)

Keep bot files thin. They should only contain the handler function and high-level orchestration:

// src/bots/send-order.ts

import { BotEvent, MedplumClient } from '@medplum/core';

import { ServiceRequest } from '@medplum/fhirtypes';

import { connectToApi } from '../common/connection';

import { buildOrderPayload } from '../common/order-utils';

export async function handler(medplum: MedplumClient, event: BotEvent): Promise<void> {

const order = event.input as ServiceRequest;

const client = await connectToApi(medplum);

const payload = buildOrderPayload(order);

await client.submitOrder(payload);

}

Shared modules (src/common/)

Shared modules are plain TypeScript — no Medplum-specific structure required:

// src/common/connection.ts

import { MedplumClient } from '@medplum/core';

export async function connectToApi(medplum: MedplumClient): Promise<ApiClient> {

const secret = await medplum.getProjectSecret('API_KEY');

return new ApiClient(secret);

}

What belongs in common/

Move code to src/common/ when:

Keep code in the bot file when:

Deployment

The deploy workflow is the same as described in Bots in Production. Your medplum.config.json points each bot at its source and compiled files:

{

"bots": [

{

"name": "send-order",

"id": "<BOT_ID>",

"source": "src/bots/send-order.ts",

"dist": "dist/bots/send-order.js"

},

{

"name": "receive-result",

"id": "<BOT_ID>",

"source": "src/bots/receive-result.ts",

"dist": "dist/bots/receive-result.js"

}

]

}

Deploy all bots in one command:

npm run build

npx medplum bot deploy '*'