lucidAGENTS
OperateDeploy

Deploy with Express

Bridge Node request/response streams to Lucid's canonical Fetch handlers without consuming payment bodies twice.

Use Express when Lucid must live inside a Node-style application. The adapter creates an Express app, turns the original incoming stream into a Web Request, calls the canonical route handler, and pipes the Web Response back to Node.

The bridge must see the original body. A global body parser mounted before Lucid can consume it and break invoke, stream, task, or payment verification.

Bind routes with correct middleware order

import { createAgentApp } from '@lucid-agents/express';

export const runtime = await buildRuntime();

const { app: agentApp, addEntrypoint } = await createAgentApp(runtime, {
  beforeMount(app) {
    app.disable('x-powered-by');
    app.set('trust proxy', 1); // Match the exact trusted proxy topology.
    app.use(securityHeadersMiddleware);
  },
  afterMount(app) {
    app.get('/ready', async (_request, response) => {
      const ready = await checkRequiredDependencies();
      response.status(ready ? 200 : 503).json({ ready });
    });
    app.use(redactedErrorMiddleware);
  },
});

addEntrypoint(capability);
export const app = agentApp;

If this app is mounted in a larger Express application, put it before the larger application's express.json()/urlencoded() middleware, or explicitly exclude every Lucid base-path route from those parsers:

const root = express();
root.use(agentApp);
root.use(express.json({ limit: '256kb' })); // Non-Lucid routes only.

When http({ basePath: '/api/agent' }) already prefixes Lucid routes, mount the adapter at root. Do not mount it again at /api/agent.

Start and stop the server

src/index.ts
import { app, runtime } from './lib/agent';

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, () => {
  console.log(`Listening on ${port}`);
});

let shuttingDown = false;
async function shutdown(signal: string): Promise<void> {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`Received ${signal}; draining`);

  await new Promise<void>((resolve, reject) => {
    server.close(error => (error ? reject(error) : resolve()));
  });
  await runtime.close();
}

for (const signal of ['SIGINT', 'SIGTERM'] as const) {
  process.on(signal, () => {
    void shutdown(signal).then(
      () => process.exit(0),
      error => {
        console.error('Shutdown failed', error);
        process.exit(1);
      }
    );
  });
}

The platform should remove readiness before sending the termination signal and allow enough grace time for bounded invokes/streams. Force-close only after the deadline and preserve durable leases for recovery.

Proxy and public URL

The adapter builds the Web Request URL from Express protocol, host, and originalUrl. Configure trust proxy only for known proxy hops so an untrusted client cannot forge public scheme/host metadata. Verify generated Agent Card interface URLs against the external origin after deploy.

At the reverse proxy:

  • enforce request/header limits before Express without rewriting the signed target unexpectedly;
  • forward host/proto consistently;
  • disable caching/transformation for payment responses;
  • preserve duplicate/set-cookie semantics required by the app;
  • set TLS and security headers at one reviewed layer.

SSE streaming

The adapter converts the Web response body to a Node stream and pipes it to Express. Do not enable buffering compression middleware for text/event-stream. Configure proxy buffering off, flush headers, retain connection-close/abort propagation, and set idle timeouts beyond the maximum documented stream.

Test streaming through the exact load balancer and CDN, not only against localhost. A server can pass unit tests while the proxy releases every event at the end.

Storage, replicas, and workers

The same rules as other adapters apply:

  • in-memory payment, SIWX, idempotency, task, and scheduler state is per process;
  • SQLite is a one-persistent-host option for payment/SIWX only;
  • multiple replicas need shared Postgres payment/SIWX and custom durable idempotency/task/scheduler ports;
  • MPP replicas need the shared Postgres challenge store;
  • scheduler timers and recoverable tasks belong in long-lived workers with correct shared leases.

Apply migrations separately before readiness, bound database pools per replica, and close the runtime during shutdown.

Container and process manager

The generated Express project builds a Bun-targeted dist/index.js and starts it with bun run start. Use the same pinned OCI pattern as the Hono guide, or configure a process manager to send SIGTERM, observe readiness/drain, and restart only after exit.

Do not use cluster/multiple processes with in-memory budgets or idempotency. Each worker would enforce an independent limit.

Canary and diagnostics

curl --fail https://service.example/health
curl --fail https://service.example/.well-known/agent-card.json
curl -i https://service.example/entrypoints/quote/invoke \
  -H 'content-type: application/json' \
  -H 'idempotency-key: deploy-express-canary-000001' \
  --data '{"input":{"symbol":"ETH"}}'

Require 402 for the unpaid canary, then one low-limit paid 2xx with PAYMENT-RESPONSE, schema-valid output, and matching external/local records. Also test invalid JSON after middleware composition, a long SSE stream through the proxy, graceful SIGTERM, restart, and same-key replay.

SymptomLikely deployment cause
Invalid/empty body only in ExpressParser consumed the request before the bridge
Agent Card uses http or internal hostTrusted proxy/forwarded host configuration is wrong
Stream arrives all at onceProxy or compression middleware buffered SSE
Duplicate spend after scalingPer-process storage remained enabled
Requests fail during deployReadiness/drain/termination grace is incomplete

Roll back only to a store-schema-compatible image and preserve payment evidence. See the Express package reference, durable storage, and deployment overview.

On this page