lume-a2a in Practice: Any lume-Family App Joins the A2A Network

🇨🇳 中文版

Let me start with a set of numbers: it began with two real running backend nodes (crm, scanner), then the default fleet grew to 7 nodes, 6 console pages, and 5 declared capabilities.

The point worth stating up front: this structure was never tailor-made for "two apps"—it was designed so that any lume-family app can join the A2A network. Any lume app that natively exposes an Agent Card and /a2a can plug in through the same three-layer structure; going from 2 nodes to 7 only added catalog entries, never a rebuild.

Behind these numbers is the same question—when real running lume apps sit side by side, how should the console be organized? I spent a fair amount of time on this, and it finally converged into a three-layer structure: lume as the server, lume-core providing the language and lib/peer.lume, and the React console interfacing directly with the nodes. This article records chronologically why I divided it this way—and what it grew into afterward.

Pain Points First, Architecture Second: Three Troubles When Two Apps Coexist

At first I just wanted to get one app running. lume is the server, lume-core is the language core plus lib/peer.lume, and the combination can start a genuinely running app. The trouble came after the second app was connected:

First, the correspondence between ports and processes gets messy. Each app occupies its own port, and without a clear entry contract, the frontend can easily send requests to the wrong address.

Second, the console's interaction logic forks. Each app has its own capability set, and if you wrap it with one generic UI, every page ends up stuffed with conditional branches.

Third—and this is what I care about most: if the language layer fragments, the first two become secondary. So I first pinned down lib/peer.lume in lume-core as the shared expression on the peer side, then built upward.

Architecture Walkthrough · Layer One: lume as the Server

I position lume as the server. It doesn't care what the console looks like; its job is to run the app and expose the port. This is the foundation of the whole system—every node uses lume as its host, so "many apps" is not many tech stacks, but many app definitions on the same host.

This trade-off has a direct benefit: the frontend's integration surface can be unified. The React console faces "a running node + its address," not "a node of a particular implementation."

Architecture Walkthrough · Layer Two: lume-core and lib/peer.lume

lume-core is the layer I value most in the entire project, because it carries the language and lib/peer.lume. Why emphasize lib/peer.lume separately? Because "nodes can talk to each other" is essentially a matter of agreement between peers. If each app invents its own communication semantics, the console can't use one set of views to present multiple sets of interactions.

I converged the peer-side expression into the single point of lib/peer.lume: signing, verification, and one-shot requests all reuse this one copy, unchanged by a single character. Two implementations are bound to drift, and the symptom of drift is "one side passes, the other returns 401"—a hole I've hit far too many times.

The pitfall I hit here: I once tried to stuff the console's presentation-layer logic into the language layer, and both sides got heavier. Later I drew the boundary cleanly—lib/peer.lume only expresses peer semantics; views belong to the React console.

Architecture Walkthrough · Layer Three: The React Console Interfaces Directly with Real Nodes

The React console's position is "interfacing directly with the running nodes." Note the word "directly": there is no additional gateway or proxy layer in between; the console talks straight to the node addresses. Cross-node calls have exactly one entry point (fleet.invoke), so the "Agent process" page can see every single call it decides to make.

This brings a trade-off that must be faced squarely: the console must keep multiple connection states alive at once. My approach is to separate "which node to connect to" from "how to display"—the connection layer only recognizes addresses and node identifiers, and the view layer renders the capabilities of whatever page is active. The console now has 6 pages, each answering one question:

Page The question it answers
Ask Agent One cross-node question; it decides which nodes to query
Customer Ops Which industry's customers to contact today, and why
Entity Alignment USCC as the entity key: current coverage and gaps
A2A Negotiation Two agents each hold constraints; how do they reach a workable deal
Agent Process What actually happened between nodes (call trace, including failures / empty returns)
Tasks Where a delegated task stands: working checks the executor before settling to a terminal state; cancellable

The reason I insist on "direct" is that this project's goal is to make the structure operable on real apps, not to pile on a layer of abstraction to pretend at unification. Only with real nodes, real ports, and real peer exchanges can the console verify whether this three-layer division holds up.

From Two to Seven: How the Fleet Grew

Let me be clear about one premise up front: these 7 nodes have no strong business correlation. crm is customer operations, scanner watches sector movements, tsm-hub monitors the LLM gateway, cicdkit manages CI/CD, lan watches the LAN, talentlens digs talent signals, and resolve-lume runs PSE orchestration—different domains, different origins, independent lume apps. The only reason they can sit side by side in one fleet is that they are all lume apps—5 of them natively expose full A2A endpoints, while tsm-hub/cicdkit join via signed peer HTTP first (their A2A endpoints are still being filled in); the protocol layer aggregates them by one shared convention rather than fusing them into one system.

The starting point really was just crm and scanner: they happened to form one customer-ops thread ("which industry to contact today"—scanner gives sector movement, crm gives "how many customers in that industry are in follow-up"), so I wired them in first to get the pipeline running. But that's a coincidental complement between two apps, not some "business mainline"—the later nodes aren't on that thread at all; they joined purely because they too are lume apps that speak A2A, and plugged into the same structure.

The default fleet later grew to 7 nodes, each minding its own domain without assuming the others exist:

Node Role
crm / scanner Business mainline (customer ops); signed direct connect
tsm-hub Read-only snapshot: LLM gateway health / usage
cicdkit Read-only snapshot: CI/CD knowledge / deployments
lan Read-only snapshot: LAN online / traffic / DNS
talentlens Read-only snapshot: GitHub talent signals
resolve-lume Capability catalog (resolve.*) + executor (exec.*)

Worth calling out: most of these 7 nodes are themselves A2A peers, not merely "read-only sources the a2a hub pulls once." Each lume app's peer_node.lume (crm, scanner, lan, talentlens, blog) natively exposes /.well-known/agent-card.json and POST /a2a, all reusing the same lume-core/lib/peer.lume signing layer. The only difference is how heavy the capability is, and the agent_backed field in each Agent Card reflects that honestly: crm has no LLM reasoning surface and hard-codes false (message/send just picks one read-only tool by keyword, not a model answering); scanner sets agent_backed dynamically based on an agent ping; lan has a real reasoning agent behind it and declares true; talentlens is a static capability surface (a hand-built card that only declares tool endpoints). tsm-hub/cicdkit currently mainly speak signed HTTP calls (/peer/invoke), with A2A endpoints still being filled in; resolve-lume doubles as both capability catalog and the one executor. What the a2a hub does is aggregate these peers' cards and endpoints into one fleet—the console faces "7 uniformly managed fleet nodes, 5 of which are already full A2A peers and the rest joining via signed peer HTTP," not "7 read-only data sources."

Once there are that many nodes, "what each node exposes" becomes a discipline. The current rule is a single source of truth for the capability catalog (catalog.lume): the frontend panel, the Agent Card, and dispatch all share the same copy, so you never get the "the card says it's there, but clicking returns 404" mismatch. There are 5 declared capabilities (sector.movement / customer.reach / entity.resolve / task.delegate / node.capabilities), and the agent surface registers 25 tools, all no-arg, read-only snapshots—the node side trims the fields first (no argument values, no credentials), and a2a passes them through verbatim.

The A2A Protocol Surface: agent-card, message/send, and the Task Lifecycle

The last section said "how many nodes"; this one says "how the nodes actually talk." The whole A2A surface is assembled from just three pieces, all built on the A2A v1.0 spec with a JSON-RPC binding:

1. Capability discovery — GET /.well-known/agent-card.json. When a peer wants to know what a node can do, the spec's entry point is this card; no out-of-band "know the port" trickery. The card is generated by card() in src/a2a.lume, and its skills field is taken straight from catalog.lume, which structurally prevents the "card says it's there, click returns 404" mismatch. Per the A2A spec, the agent-card is a public, unauthenticated endpoint (it's there for humans/clients to probe). An easy-to-hit detail: when a node binds 0.0.0.0 but PEER_PUBLIC_URL is unset, the url in the card writes 127.0.0.1, so a cross-machine client simply can't reach it—in that case card() writes a warnings entry into the card rather than silently swallowing it.

2. Message channel — POST /a2a, JSON-RPC message/send. The request carries params.message.parts (text) and an optional contextId; the response/task carries status.state. Sending message/send again with the same taskId means "keep talking to this task"—the spec's semantics for multi-turn continuation (e.g. the first round returned input-required, the client tops up the arguments and sends again, and the task advances in place rather than being recreated). The task lifecycle splits into two classes per A2A v1.0: completed/failed/canceled/rejected are terminal; input-required/auth-required are interrupted states—the spec's exact words for input-required are "This is an interrupted state": the task pauses for input, and sending again with the same taskId and topped-up arguments resumes it in place. One implementation deviation to disclose honestly: STATE_DONE in src/a2a.lume:170 also folds input-required into the done set, tighter than the spec (it treats it as "no longer auto-advancing")—keep that in mind when building resume interactions. a2a is the sole writer of task state (a rule tightened again in the next section's task.delegate). The data nodes (crm/scanner and so on) also implement message/stream: it executes synchronously, then replays working → artifact → completed as a one-shot SSE—not a genuine streaming progress feed. The streaming: true in the Agent Card means the SSE event channel is spec-compliant (the comment at node.lume:504 is blunt about replay vs. real-time), not that there is incremental progress pushed while running.

3. Signing — every peer call passes through the one copy in lib/peer.lume. The signing string is node\n ts\n nonce\n scope, with three modes (HMAC shared key / derived key / Ed25519 asymmetric; priority ed25519 > derived > shared) and time-window-level replay protection. This one implementation is the whole family's only copy: the sibling apps' peer_node.lume (crm/lan/scanner/talentlens/blog) and a2a's node.lume all import it, and no node is allowed to write its own verification logic.

Two security layers must be kept apart to avoid misreading: the A2A v1.0 JSON-RPC endpoint (POST /a2a) itself carries no authentication—this implementation honestly accepts plaintext calls (the route at node.lume:490 does no verification), which is fine for demos and intranets, but going public requires transport-level authentication on top. What actually carries HMAC/Ed25519 signing is lume's own peer channel (/peer/invoke and relay)—a project-specific mechanism, not A2A's standard authentication. Keys never reach the browser: the console source states the reason bluntly (src/ui/api.ts:97—"putting PEER_SHARED in the frontend equals handing the key to every visitor"); the browser sends only business arguments, and signing is done server-side (/api/local-invoke, node.lume:376, which accepts same-origin targets only so it can't become an open proxy) or via relay; node-to-node calls are all signed.

task.delegate: The Query Side Borrows the Execution Side's Hands

Once the fleet reached 7 nodes, a natural question was: can the console also offload the "get something done" work? My answer was yes, delegate it—but never do it by hand, and I hardened that into a rule:

a2a is the query workbench; resolve-lume is the execution workbench.

The concrete path:

task.delegate (a2a receives it)
  → signed fleet call to resolve-lume's exec.submit
  → the sidecar spawns a background job (the real spawn/exec lives here) → returns job_id
  → a2a's task stays at working, recording only {executor, job_id}
  → on tasks/get, lazy advancement: query exec.status, settle completed/failed/canceled

Three rules:

  1. a2a is the sole writer of task state—the executor only acts as a signed proxy; it never calls back or writes a2a's task files;
  2. Lazy advancement, no resident poller—there's no background process waiting; tasks/get advances it one step on the way (the old host-side poller is now archived as dead code);
  3. No fallback—if the executor is unreachable, report input-required plainly; never silently switch to running it inside a2a. Strictly speaking this borrows the spec's "interrupted state": a missing prompt argument is a genuine input-required (src/a2a.lume:320, standard semantics—top up the arguments and it resumes); mapping an unreachable executor onto it too (:333) is a conservative choice whose meaning is closer to "the caller must intervene (bring the executor up / pick another target / retry)" than "supply an argument"—compare the lazy-advance stage, which keeps working for a temporarily unreachable executor (see below), so the two scenarios are not conflated.

Why draw this line? Because the moment someone gives a2a "hands" (registers write tools, opens a write endpoint), it degrades from a query workbench into a junk drawer—and no error will ever warn you at that moment: the boundary is held up by discipline, not a switch in the code. resolve-lume itself is a ready-made example: the only heavyweight node in the family that actually acts (PSE orchestration plus a real host-tool layer), yet when it plugs into a2a it only shows the "menu" (the skills/tools catalog, no argument values), never the "hands."

One more data-flow point to make explicit: in task.delegate, the terminal answer is the executor's output. If a day comes when the delegated job reads host data, that answer may carry sensitive content and get persisted by a2a—so judge the delegated job itself for sanitization before delegating; never casually delegate "read real host files / credentials."

Review: Does This Decision Line Hold?

Looking back at these layers, there is only one criterion: does each layer handle exactly one thing.

  • lume as the host runs; lib/peer.lume in lume-core owns the peer semantics; the React console owns presentation and connection—each of the three handles one thing, so growing from 2 to 7 nodes didn't require a rebuild, only new entries in the catalog and a role line;
  • The query/execution split (task.delegate) keeps the "console" permanently on the query side; the hands always stay on the execution workbench—no matter how many nodes, this line won't blur;

If I could start over, I would have drawn the boundary of lib/peer.lume earlier rather than tightening it after both apps were connected, and I would have written the "query side / execution side" line into discipline earlier rather than patching it only after the fleet reached 7 nodes. That is the most genuine lesson from this project.

Source Evidence (excerpt)

Negotiation is not "one side proposes, the other accepts" — it is two sides each holding constraints and vetoing with a reason. src/negotiate.lume:76's customer_veto requires every constraint to carry a reason, otherwise it degrades into a switch that can be bypassed at will:

func customer_veto(prop, ctx) {
  let sign = get(prop, "signaled", false);
  if (sign != true) {
    return { veto: true, code: "NO_SIGNAL",
             reason: "该标的当日无扫描信号, 无法给客户一个有依据的推荐理由" };
  }
  let r = graph.resolve("", "", str(get(prop, "code", "")));
  if (str(get(r, "status", "")) != "matched") {
    return { veto: true, code: "UNALIGNED_ENTITY",
             reason: "该标的未在实体索引中确认(USCC/代码), 无法确认对应哪家工商主体" };
  }
  let ch = float(get(prop, "change", 0.0));
  if (ch < -5.0) {
    return { veto: true, code: "TODAY_DROP",
             reason: "当日跌幅 " + str(ch) + "%, 不适合作为当日推荐开场" };
  }
  return { veto: false, code: "", reason: "" };
}

A2A Protocol-Surface Evidence: card(), cap_delegate, observe_delegate

The negotiation section above is about "two sides each holding constraints," but the three code blocks of the A2A protocol surface are what truly set this system apart from "yet another REST shell" — discovery, delegation, and task-state all converge in one place: src/a2a.lume. (The lume source below keeps its original Chinese comments, as it is the real file.)

The Agent Card is generated, not hand-written. card() (src/a2a.lume:240) takes its skills straight from catalog.as_skills(), so the "card says it's there, click returns 404" mismatch is prevented structurally; when a node binds 0.0.0.0 but PEER_PUBLIC_URL is unset, it writes a warnings entry into the card instead of silently swallowing it:

export func card() {
  let base = base_url();
  let warn = "";
  if (self_bind == "0.0.0.0" and len(public_url) == 0) {
    warn = "节点绑定 0.0.0.0 但未设 PEER_PUBLIC_URL: card 宣告 " + base +
           ", 跨机客户端请把 PEER_PUBLIC_URL 设成本机可达地址";
  }
  return {
    name: node_name(),
    description: "Lume A2A 演示节点: 展示 peer 之间如何发现(Agent Card)、" +
                 "签名调用(message/send)、多跳转发与任务生命周期。" +
                 "只读能力(sector/customer/entity)不下探个股; task.delegate 把要动手的活派给执行工作台, 本节点不自己执行。",
    version: "1.0.0",
    protocolVersion: "1.0",
    supportedInterfaces: [
      { protocolVersion: "1.0", protocolBinding: "JSONRPC", url: base + "/a2a" },
    ],
    url: base + "/a2a",
    capabilities: { streaming: true, pushNotifications: false, agent_backed: false },
    defaultInputModes: ["text/plain", "application/json"],
    defaultOutputModes: ["text/plain", "application/json"],
    skills: catalog.as_skills(),
    warnings: warn,
  };
}

"a2a never does the work itself" in task.delegate is a code fact, not a slogan. cap_delegate (src/a2a.lume:315) has no spawn/exec of its own; it only hands the job to the executor through the signed peer channel fleet.invoke_args(target, "exec.submit", ...). If the executor is unreachable, it honestly returns input-required and never silently falls back to running it inside a2a — that would suddenly give a2a an execution surface:

func cap_delegate(args) {
  let target = str(get(args, "executor", ""));
  if (len(target) == 0) { target = "resolve-lume"; }
  let prompt = str(get(args, "prompt", ""));
  if (len(prompt) == 0) {
    return { state: "input-required", artifacts: [],
             note: "task.delegate 需要 prompt(交给执行工作台的指令)" };
  }
  let payload = { prompt: prompt };
  let tid = str(get(args, "taskId", ""));
  if (len(tid) > 0) { payload = put(payload, "taskId", tid); }
  let model = str(get(args, "model", ""));
  if (len(model) > 0) { payload = put(payload, "model", model); }
  let t0 = int(now());
  let r = fleet.invoke_args(target, "exec.submit", payload, 30);
  let ms = (int(now()) - t0) * 1000;
  if (!get(r, "ok", false)) {
    return { state: "input-required", artifacts: [],
             note: "executor '" + target + "' 不可达: " + str(get(r, "error", "")) +
                   "(需该节点在线且其 sidecar 已启动)", job: null };
  }
  let j = get(r, "result", {});
  let job_id = str(get(j, "job_id", str(get(j, "id", ""))));
  return { state: "working", artifacts: [],
           note: "已派给 executor '" + target + "' (job_id=" + job_id + ", " + str(ms) + "ms)",
           job: { executor: target, job_id: job_id,
                  submitted_at: int(now()), prompt: prompt } };
}

a2a is the sole writer of task state, and the executor never calls back — this rests on the lazy advancement in observe_delegate (src/a2a.lume:349): only on tasks/get does it query the executor's job state and settle completed/failed/canceled; if the executor is temporarily unreachable it keeps working for next time rather than marking failed:

export func observe_delegate(id: string) {
  let t = load(id);
  if (t == null) { return null; }
  if (str(get(get(t, "status", {}), "state", "")) != "working") { return t; }
  let payload = get(get(t, "metadata", {}), "payload", {});
  let exec = str(get(payload, "executor", ""));
  let job_id = str(get(payload, "job_id", ""));
  if (len(exec) == 0 or len(job_id) == 0) { return t; }
  let r = fleet.invoke_args(exec, "exec.status", { job_id: job_id }, 5);
  if (!get(r, "ok", false)) {
    return t;  // executor 暂时不可达: 保持 working, 下次 tasks/get 再试(不记成 failed)
  }
  let job = get(get(r, "result", {}), "job", get(r, "result", {}));
  let jstat = str(get(job, "status", ""));
  if (jstat == "succeeded") {
    let ans = str(get(job, "answer", ""));
    return advance(id, "completed", [artifact(ans)], "executor succeeded (job " + job_id + ")");
  }
  if (jstat == "failed") {
    return advance(id, "failed", [], "executor failed (job " + job_id + ")");
  }
  if (jstat == "cancelled" or jstat == "canceled") {
    return advance(id, "canceled", [], "executor canceled (job " + job_id + ")");
  }
  return t;  // running: 保持 working
}

To reproduce this negotiation and fleet check locally, run two Makefile targets (src/agent.lume:277's registered() is one of their data sources):

make fleet-verify    # per-node assertions: online/signing/real fields/whitelist/on-site compare
make llm-negotiate   # run one multi-round negotiation, write data/self/llm-negotiation.json

Source Code Navigation

  • node.lume (server entry: routing + A2A JSON-RPC + boot self-check)
  • src/a2a.lume (task state machine / Agent Card / dispatch; the single implementation across three paths)
  • src/fleet.lume (the single cross-node entry + trace instrumentation)
  • src/catalog.lume (capability catalog; the one source for frontend / Agent Card / dispatch)
  • src/agent.lume (25 read-only tool registrations, pure lume)
  • lume-core/lib/peer.lume (peer-side semantics)
  • src/ui/ (React console; esbuild bundles into www/, served straight by the server)
  • docs/architecture.md (architecture and module layout)

Note: The above is directory-level navigation; actual file names are subject to the code repository erishen/lume-a2a.

What parts does lume-a2a consist of?

Three layers—lume (server host), lume-core (language and lib/peer.lume), and the React console. Cross-node calls have exactly one entry point (fleet.invoke), and peer semantics are converged into a single copy of lib/peer.lume.

How many nodes does it connect to by default now?

7: crm / scanner (business mainline) + tsm-hub / cicdkit / lan / talentlens (read-only snapshots) + resolve-lume (capability catalog + executor). Overridable via PEER_FLEET.

How does the React console interface with the nodes?

Directly, with no extra gateway layer. Cross-node calls all go through the signed channel, and the console can see every call (the “Agent Process” page).

What is task.delegate for?

It offloads the “get it done” work to an executor (resolve-lume’s sidecar by default). a2a only records task state and acts as a signed proxy; it never does the work itself. tasks/get lazily advances by querying the executor. The boundary is held by discipline: write tools don’t enter a2a’s agent surface.

Why separate the language layer from the presentation layer?

Converge peer semantics in lib/peer.lume and leave views to the React console, so the two layers don’t drag each other down. Two signing implementations are bound to drift; the symptom is one side passes while the other returns 401.

Do the connected apps themselves support A2A?

Yes. crm / scanner / lan / talentlens / blog each expose /.well-known/agent-card.json and POST /a2a natively in their peer_node.lume, all reusing the same lib/peer.lume signing layer. Each node self-reports its capability weight honestly: crm hard-codes agent_backed: false (no LLM reasoning surface), lan has a real agent behind it and declares true, and scanner decides dynamically from an agent ping. What the a2a hub does is aggregate their cards and endpoints into one fleet.

What is the project's GitHub address?

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号