Unit Backends: one governed tool over many private backends
A Unit Backend bundles one or more private sub-backends (MCP or REST) behind a single JavaScript glue module and exposes only its own, curated tools. The sub-backends never appear in the tool manifest — the model sees a handful of purpose-built tools, while the unit does the multi-step work, the composition, and the response shaping inside ToolMesh.
Unit Backend vs. Composite Tool
Section titled “Unit Backend vs. Composite Tool”ToolMesh has two composition mechanisms, and they are not the same:
| Composes | Lives in | |
|---|---|---|
| Composite tool | other tools of the same backend | a DADL file |
| Unit Backend | other backends (MCP or REST) | a unit.yaml + a JS module |
A Unit is the right tool when one model-facing call should orchestrate several backend calls, join their results, or hide a verbose API behind a compact answer.
When a Unit earns its place
Section titled “When a Unit earns its place”Wrapping is only worth it if the unit does something the raw tools plus the model cannot do cheaply. Three wins recur — and all three apply even with a single sub-backend:
- Multi-step capture. A clean tool that hides a mandatory discover-then-query dance.
- Cross-tool composition. One digest assembled from several calls.
- Response reduction. Big, nested or tabular payloads collapsed to the few fields that matter — real context-window savings.
The worked example below leans on exactly these.
Anatomy
Section titled “Anatomy”A unit is a directory under TOOLMESH_UNITS_DIR (default /app/config/units), scanned as a direct child:
unit: netdataimplementation: ./netdata.jsexpose: meta_signals: [netdata] # which _meta.* branches the Output Gate may pass through audit: full # full | compact | none tools: [health_summary] # optional: promote these to direct top-level MCP toolsbackends: # private dependencies — same syntax as backends.yaml - name: netdata transport: http url: http://your-parent:19999/mcp api_key_env: netdata_mcp_api_key # injected as Authorization: Bearer <key>The
unit.yamlroot must be a mapping (unit:/implementation:/expose:/backends:). Only the entries underbackends:are a list.
expose.tools is optional and mirrors expose_tools from backends.yaml: the listed describe() tool names are additionally promoted to direct top-level MCP tools. Promotion always uses the full <unit>_<tool> name, never a bare alias — unit tool names like search or roll are too generic for the root level. Either way, every unit tool stays reachable through discover_tools and execute_code; an entry that names no describe() tool is logged once at load time and skipped.
The implementation is a JavaScript module that provides a top-level describe() (the tool surface) plus one function per declared tool. Inside, private sub-backends are reachable as api.<name>.<tool>(args):
function describe() { return { tools: [{ name: "health_summary", access: "read", description: "Connected nodes plus all raised alerts, in one digest." }] };}
async function health_summary() { const nodes = unwrap(await api.netdata.list_nodes({})); const alerts = unwrap(await api.netdata.list_raised_alerts({ cardinality_limit: 200 })); // ...parse, merge, trim... return { content: [{ type: "text", text: JSON.stringify(/* compact digest */) }], _meta: { netdata: { tool: "health_summary" } } };}Sandbox
Section titled “Sandbox”The glue runs per call in a locked-down goja runtime:
- Allowed:
api.<sub>.<tool>(...), theparamsobject, pure JS (Math,Date,JSON,await). - Forbidden:
fetch,require,import,fs,process,eval, timers. - Limits: call depth 16, max 20,000
api.*calls per invocation (runaway guard).
Gotcha worth knowing. Inside the sandbox,
api.<sub>.<tool>()returns the raw MCPToolResult—{ content: [{ type: "text", text: "<json>" }], isError, metadata }— not the parsed payload. Parsecontent[0].textyourself (theunwrap()helper above). Theexecute_codemeta-tool auto-unwraps, so the same code can pass an inline test yet return empty inside a unit. Always verify unit logic through the loaded unit.
Worked example: Netdata
Section titled “Worked example: Netdata”Netdata’s monitoring agent ships a built-in MCP server (free, open source, http://HOST:19999/mcp on any Agent or Parent — no Netdata Cloud account required). It exposes ~13 powerful but low-level tools. We register that MCP as a private sub-backend and expose a small curated surface:
| Unit tool | What it does | Raw equivalent |
|---|---|---|
metric | per-group summary (avg/min/max/anomaly%) for any context | get_metrics_details → query_metrics + parse a deep nested object |
compare_nodes | A↔B or now↔then delta/ratio for one context | same two-call dance, twice |
health_summary | nodes + raised alerts in one digest | list_nodes + list_raised_alerts + parse two tables |
anomalies_now | ML-flagged anomalies, ranked & trimmed | find_anomalous_metrics + parse an 11-column table |
what_changed | metrics that shifted vs. a baseline window | find_correlated_metrics with hand-set baseline windows |
The payoff, measured
Section titled “The payoff, measured”Querying system.cpu across five nodes:
- Raw
query_metricsreturns a nestedsummary/result/view/dbobject — ~7.9 KB — and requires you to pass explicit dimensions first (so it is really two calls), plus it silently warns when you average dimensions together. metricauto-discovers the dimensions, picks a sane aggregation, and returns ~0.8 KB:
{ "context": "system.cpu", "units": "Total CPU utilization", "grouped_by": ["node"], "groups": [ { "name": "fermat", "avg": 4.25, "max": 4.25, "anomaly_rate_pct": 0 }, { "name": "dumfries", "avg": 1.40 }, { "name": "nomad-node-1", "avg": 1.20 }, { "name": "nomad-node-0", "avg": 1.07 }, { "name": "picard", "avg": 0.29 } ]}That is a ~90% smaller answer, the two-call workflow collapsed into one, and the aggregation pitfall handled for the model. health_summary shows the composition win — two tabular dumps become one digest — and anomalies_now the reduction win: an 11-column table over tens of thousands of analysed series becomes a short ranked list.
Running raw and curated side by side
Section titled “Running raw and curated side by side”You can register the same Netdata MCP twice: once as the unit (netdata, curated) and once as a raw passthrough (netdata_raw, the full toolset for ad-hoc use). The model then sees both netdata_* and netdata_raw_*.
Because one backend name (netdata) is a prefix of the other (netdata_raw), ToolMesh routes by longest matching prefix — netdata_raw_query_metrics always reaches the passthrough, netdata_metric always reaches the unit. Pick names that do not collide if you are unsure.
Governance
Section titled “Governance”A unit is a backend like any other, so the full pipeline applies:
- Access classification per tool via
describe()(read/write/admin/dangerous) — wrap sensitive calls (live processes, logs) asdangerous. _metasignals the unit emits are gated byexpose.meta_signalsand consumed by the Output Gate.- Audit records every
api.*call with the unit as parent context. - OpenFGA permissions are granted on the unit; that transitively authorises its private dependencies.
A note on licensing
Section titled “A note on licensing”This example stays entirely on Netdata’s open-source Agent (GPLv3): the built-in MCP, the on-agent ML/anomaly detection, and a Parent’s multi-node aggregation all work without Netdata Cloud. The unit builds its own analysis layer on that data API — it does not touch or replicate the separately-licensed Netdata Cloud UI. Connecting at arm’s length over MCP/HTTP keeps ToolMesh (Apache-2.0) clean, since the Agent is GPL, not AGPL.