Writing a Complete CRM with Lume: One Business Layer for Web and Agent

🇨🇳 中文版

Introduction

lume-crm is a CRM backend written entirely in Lume: customers, deals and activity tracking, plus an LLM Agent you can talk to directly.

What this project really wants to prove is not "can Lume do CRUD" but something else: can web requests and Agent actions enter the same business logic directly?

In lume-crm, HTTP routes and Agent tools both call domain-layer functions directly. Validation and SQL for customers, deals and activities exist in exactly one copy; the Agent is not an extra layer bolted on, but another entry point into the same business functions.

Database, REST API, SSR share pages and Agent tools are all powered by three .lume files, compiled into a single C11 binary. The frontend is still standard React, but no Node service is needed at runtime.

This post does not re-explain what Lume is, how to install it, or how it works internally (that's covered at erishen.cn/lume). Instead it looks directly at how a complete application is organized. Every snippet below is taken straight from the repository.

What this CRM does (thirty-second version)

Dashboard stats + customer list/detail + deal pipeline (Initial Contact → Proposal → Negotiation → Won/Lost) + activity log (calls/meetings/emails/visits) + Agent chat + per-customer SSR share pages + a teaching page. The features are not the point; the point is that the entire server side of all this is three .lume files — routes, SQL, tools and page rendering all live in them, with no second server process.

The overall structure first

The core structure of lume-crm is actually simple:

  React SPA ──► HTTP routes ──┐
                              │
  LLM ──────► Agent tools ────┼──► Domain functions ──► SQLite
                              │
  SSR share pages ────────────┘

The important part is the Domain functions in the middle. HTTP routes and Agent tools call the same set of business functions — POST /api/customers and the crm_add_customer tool end up in the same add_customer(). It's not a second Agent layer bolted onto the CRM; it's one business function that happens to be the entry point for both the web API and the Agent. Everything in the code below revolves around this.

Entry point: one file declares the whole site

crm.lume is the single entry point, opening with the whole site's declaration:

let crm_bind = env("LUME_BIND");
if (crm_bind == null) { crm_bind = "127.0.0.1"; }
server {
  port = 8089;
  workers = 2;
  bind = crm_bind;
  docroot = "./www";
  spa = true;        // SPA history routing: static 404 falls back to index.html
  htpasswd = env("HTPASSWD_FILE");
};

A few things worth noting:

  • spa = true solves SPA history routing in one line — refreshing a deep link like /customers/3 would otherwise 404; this line makes static 404s fall back to index.html while API 404s stay untouched. No nginx fallback to configure.
  • bind is read from an environment variable — local dev defaults to 127.0.0.1, and containers pass LUME_BIND=0.0.0.0 so the port mapping can reach it. Deployment shape is expressed with env, not by editing the script.
  • htpasswd takes a file path directly — Basic Auth is language-level: one line to enable, one line to disable.

Routes: HTTP only speaks the protocol

Read paths are nearly one line each:

get "/api/stats", (req) => {
  return crmdb.stats_payload();   // returns a map; the framework serializes it to JSON
};

get "/api/customers", (req) => {
  let q = get(req.query_params, "q", "");
  let sort = get(req.query_params, "sort", "");
  let list = crmdb.customer_list(q, sort);
  return { count: len(list), customers: list };
};

A handler returns a map and the framework serializes it as 200 application/json automatically — no stringify(), no hand-written status/type.

Write paths follow a convention this project has settled on. Creating a customer, for example:

post "/api/customers", (req) => {
  let gate = write_gate();
  if (gate != null) { return gate; }
  let b = body_of(req);
  if (b.err != null) {
    return { status: 400, body: { err: b.err } };
  }
  return resp(crmdb.add_customer(
    str(b.ok, "name", ""),
    str(b.ok, "company", ""),
    str(b.ok, "email", ""),
    str(b.ok, "phone", "")), true);
};

Three tiny helper functions do all the dirty work; every route has this shape:

  • write_gate() — in read-only demo mode the whole write path returns 403; in local dev it's null and lets requests through. Read routes never touch it.
  • body_of(req) — body parsing. An empty req.body returns { ok: null, err: "missing body" }, invalid JSON returns { ok: null, err: ... }. It always returns exactly the two keys { ok, err }, so a route only ever touches b.err/b.ok and cannot crash.
  • resp(res, created) — wraps the domain-layer result into an HTTP response: failures carry a status (500 by default), creates return 201.

The convention: routes only handle body parsing and status codes; validation, locking and SQL all live in the helpers in src/db.lume. Thin routes, thick business logic.

One sentence sums up this design: the route layer is deliberately thin — HTTP speaks the protocol, domain functions do the business, and Agent tools expose the domain to the model. HTTP, Agent and SSR each own their own way of access or output, with a single copy of the business logic behind them.

While you're here, note the three-arg form str(b.ok, "name", ""): str/int/float(m, k, default) is Lume's safe-read idiom — fetch, convert and fall back to the default in one step (equivalent to the older str(get(m, k, default))). The "Reading & error-tolerance conventions" section below covers it in more detail.

Domain layer: one copy of business logic

The CRM's business logic lives in src/db.lume. SQL is written directly in the DSL — no ORM layer needed. More importantly, the functions here serve both HTTP and the Agent — add_customer(), for example:

HTTP POST /api/customers ──┐
                          ├──► add_customer() ──► SQLite
crm_add_customer tool ─────┘

So customer validation, SQL and error handling exist in exactly one copy. If you later add a CLI, a scheduled job or another Agent, you don't reimplement the CRM logic — you just add another entry point.

SQL is part of the DSL, no ORM involved:

export func stats_payload() {
  let by_stage = {};
  for (s in q("SELECT stage, COUNT(*) AS n, COALESCE(SUM(amount), 0.0) AS amt FROM deals GROUP BY stage", null)) {
    put(by_stage, s.stage, int(s.n));
    put(by_stage, s.stage + ".amt", float(s.amt));
  }
  let open = q("SELECT COALESCE(SUM(amount), 0.0) AS t FROM deals WHERE stage NOT IN (" + in_clause(closed_stages) + ")", null);
  ...
}

q("...") executes SQLite directly; parameterized queries take arrays (q("SELECT ... WHERE customer = ?", [cid])), and aggregations are computed in SQL before being wrapped up. Stage definitions (closed_stages) exist in exactly one place, shared by both the SQL stats and the automatic logging when a deal advances — change it once and everything stays in sync.

Agent tools: declaring a business function makes it a tool

In Lume, a tool is not another API — it's the Agent's entry point to a business function. Existing domain helpers become tools the Agent can call just by adding a tool declaration:

tool "crm_add_customer", "新建客户。name 必填;company/email/phone 可空(空串占位)。", { name: string, company: string, email: string, phone: string }, (arg) => {
  return add_customer(arg.name, arg.company, arg.email, arg.phone);
};

tool "crm_add_activity", "给客户记一条跟进(电话/会议/邮件/拜访…),note 必填;kind 空串默认 记录。", { customer_id: int, kind: string, note: string }, (arg) => {
  return add_activity(arg.customer_id, arg.kind, arg.note);
};

Key points:

  • Parameters use bare type keywords ({ name: string, customer_id: int, amount: float }); the JSON schema shown to the model is generated by the compiler, not hand-written.
  • The description to the LLM is part of the declaration — constraints like "name required" or "empty kind defaults to 记录" are written straight into the declaration, and the model generates parameters from them.
  • Tools and HTTP routes call the same helper functions — saying "log a phone call" in chat and curling POST /api/activities go through the same validation and SQL. There's no "works in the UI but not in chat" divergence.

All 10 crm_* tools (CRUD over customers/deals/activities) are declared this way, one line each.

What this means: a traditional app usually designs the REST API first, then separately designs tool interfaces for the Agent / MCP, and finally has to keep the business rules on both sides consistent. This approach is the opposite: business capability is first consolidated into domain functions, then HTTP and Agent tools become different entry points to them. That's the "AI-native" part of lume-crm: the Agent does not need a parallel business implementation.

SSR share pages: another output of the same domain data

The customer share page in src/share.lume is a pure-function component using html() scalar slots for escaping:

func share_page(c) {
  return html(
    "<h1>{0}</h1>"
    + "<div class='contact'>…</div>"
    + "<section><h2>商机 Pipeline</h2>{5}{6}</section>"
    + "<section><h2>跟进记录</h2><ul>{7}{8}</ul></section>",
    str(c.name), str(c, "company", ""),
    str(c, "email", ""), str(c, "phone", ""),
    deal_rows, deal_empty, acts, act_empty);
}

SSR isn't a second business layer — it converts domain data into shareable HTML. Every customer field goes through html() slot escaping, so malicious content stored in the DB renders as plain text. The whole page has zero JavaScript, works fine on phones, and can be sent around as a customer "profile" link. Whatever parts of a SPA need server-side rendering don't require spinning up a Node service.

Frontend: standard React, artifact is static files

The frontend is a standard React SPA (four views: dashboard / customers / chat / teaching), bundled by esbuild into a static app.js artifact and COPYed into the image at build time. There's no Node process at runtime — docroot = "./www" is all there is.

Reading & error-tolerance conventions

Lume's member access is strict — reading a missing key of a sparse map throws a 500 directly. So this project goes through safe reads for all map access, in three flavors:

  • Convert in one step: the three-arg str/int/float(m, k, default) — fetch, convert and fall back to the default in a single call (equivalent to the older str(get(m, k, default)); both are legal);
  • Plain fetch: get(m, k, default);
  • Calls that may throw: try(() => ...), always returning the two keys { ok, err }.

These conventions shape every route: body parsing returns fixed two keys, helpers return sparse maps, and routes use three-arg cast-and-get / get() as the safety net. Once you're used to it, adding a new resource is basically copying a route template and adjusting the corresponding business function.

Why organize the app this way

The value of this organization comes down to three points:

  1. Web and Agent share domain functions, avoiding two business layers — HTTP routes and Agent tools call the same domain functions, while SSR share pages consume domain data directly; validation and SQL exist in one copy, which structurally prevents Web and Agent from maintaining separate business logic.
  2. A simple runtime — the server side is three .lume files compiled into one static binary; React compiles into static frontend assets, no Node process at runtime; docroot = "./www" is all there is.
  3. Compile-time errors caught early: lume --check runs a full type check offline (that's what make check does here), so route/tool/field mistakes are caught before commit instead of blowing up in production.

The natural result of these choices: a 3.8MB static binary and a ~18MB Docker image. For a small CRM or an internal tool, this deployment shape keeps the runtime footprint very small. The repo also ships an /examples teaching page with five DSL snippets, from server{} to tool declarations, that can serve directly as the starting point for a new app.

Security guardrails

These mechanisms aren't peripheral config bolted on before launch — they're written into the application DSL and runtime:

  • Writes: write_gate() controls every write route — 403 in read-only demo mode, open in local dev.
  • SQL: q() only accepts parameterized queries; no user input is ever concatenated into a SQL string.
  • XSS: React's default escaping plus html() scalar slots on the SSR share page — two lines of defense.
  • Basic Auth: mounting an htpasswd file path in server{} enables it — language-level and fail-closed, wrong credentials always 401.
  • Network: binds 127.0.0.1 by default; only container deploys explicitly pass LUME_BIND=0.0.0.0 to open the port mapping.
  • Agent chat: the underpinning agent-httpd ships same-origin + POST-only checks — cross-site requests via the chat endpoint are stopped at the gateway.

Try it

  • Live demo: https://lume-crm.erishen.cn — a public read-only instance, open and play.
  • Locally: install lume, run lume crm.lume, open http://127.0.0.1:8089.
  • Docker: docker run -d -p 8089:8089 lume-crm; SQLite lives in /app/.data/, mount a volume to persist; pass LLM_API_URL / LLM_API_KEY to connect a real LLM, or skip them and the built-in offline engine keeps chat working.

About Lume and its underpinning agent-httpd

  • Lume itself (what it is, internals, toolchain, design trade-offs; the intro links the primer): repo github.com/erishen/lume.
  • The underpinning agent-httpd: lume's chat SSE, Agent tool dispatch and tool schema generation all come from the separate open-source project agent-httpd, statically linked in as libagenthttpd.a. The same-origin + POST-only guardrails on the chat endpoint come from it too. If you want to dig into the agent gateway itself, that's the repo.
  • Starter repo: erishen/lume-crm. Want to write the next app? Copy this repo's structure.

Source map

  • crm.lume — entry: server{} + all HTTP routes + write-route conventions (write_gate / body_of / resp).
  • src/db.lume — domain layer: SQLite helpers + inline SQL + the 10 crm_* tool declarations.
  • src/share.lume — the SSR share page's pure-C rendering component.
  • src/crm/*.tsx — the four React SPA views.

FAQ

Does the server really need Node or Python?

No. The whole backend is one C11 binary (lume) plus three .lume scripts; the React frontend is a build-time static artifact — no Node process at runtime.

How big is the Docker image?

~18MB (alpine base + lume static binary, SQLite built in, zero runtime dependencies).

Are Agent tools and HTTP endpoints two separate code paths?

No. Tool declarations and routes call the same helper functions; validation and SQL exist in exactly one place.

How do I write the next app?

Copy this repo’s structure: server{} declaration → thin route layer → src/ domain helpers → tool declarations wired to the Agent; run make check for a full type check.

Comments

Leave a reply

Your email address will not be published. Required fields are marked *

AI Engineering Practices & Open Source Projects

Shop Web Chat Nsbp About Privacy

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号