Capability registry
camber.capabilities (0.110, #275; provisional) is one read-only core that CAMBER's assistant
servers expose: the MCP server (0.110) and the A2A server (0.112) are thin adapters over it, so
the rules about what a caller may see and do are enforced in one place.
A capability is a named, read-only operation with JSON Schema 2020-12 input and output
schemas and a handler. Both schemas are object schemas (MCP 2025-11-25 takes only an object
outputSchema and structuredContent), so data is always an object whose lists sit under
keys. A call returns a CapabilityResult:
| Field | What it holds |
|---|---|
data |
the structured result, valid against the capability's output schema |
text |
a short summary that cites fact ids such as [F1@points-1a2b3c] (one token per call) |
facts |
the fact index those ids name: {"id", "kind", "value", "text", ...} |
banners |
licence, truncation and citation notices, always shown to the user |
truncated |
True when any list in data is not the whole list: a page (see Pages below), or a list cut to the row cap |
A refused or failed call raises CapabilityError with a code: invalid_params,
unknown_capability (no capability by that name), not_found (the capability found nothing,
e.g. a facility with no stored data), not_allowed, refused_licence, too_large, timeout,
unavailable (the stored data could not be read; see below) or internal.
import pandas as pd
from camber.capabilities import CallContext, default_registry
from camber.model.roles import Role
from camber.store import ParquetStore
idx = pd.date_range("2024-03-01", periods=24, freq="h")
store = ParquetStore("store")
store.write_role_frame(
pd.DataFrame({Role.OAT: 60.0, Role.SUPPLY_AIR_TEMP: 55.0}, index=idx),
facility_id="hq",
equip="AHU-1",
equip_class="AHU",
)
ctx = CallContext(store="store", allowed_facilities=frozenset({"hq"}))
reg = default_registry()
print(len(reg.capabilities()), len(reg.prompts())) # 13 8
res = reg.call("points", {"facility_id": "hq"}, ctx)
print(res.text) # hq stores 2 matching points on 1 equipment [F1@points-<token>].
Built-in capabilities
Thirteen capabilities, all read-only (0.110, #275 and #278). Each result's text cites fact ids
F<n>@<capability>-<token> and repeats the cited values next to them (hq is active with 6
points [F3@facilities-1a2b3c]), so a model that reads only the text has them (rcx_report also
returns the report's own fact index, K1@summary-<token>, C1@<issue key>-<token>, ...).
Fact ids are unique per call. Registry.call appends a token to the context of every fact id
a handler returns -- in facts, in the [id] citations of text and wherever data names a
fact id (a row's fact, the RCx fact index). The token is six hex digits of SHA-256 over the
capability name, the canonical JSON of the validated arguments, the sorted allowlist and what
else decides the values (0.111, #295): a digest of the loaded run config (its content, not its
path), the trust floor and a store generation (the size and modification time of the facility
registry, the point catalog and every file of each facility the call reads, part files included,
so an in-place rewrite counts, #312: the facilities its arguments name, else every allowed one). So the same call on the same server and unchanged data gives the same ids, also after a
restart, while two different calls, or the same call on two servers that differ in --config,
--min-trust or stored data, or after an ingest, give different ones: a model cannot cite
F1@rule_run from one call while meaning another, and the facts of several calls, from one
server or several, can be merged for check_citations without a clash. The token is a
collision-resistant name, not a signature: it does not prove a fact came from a server. A handler writes plain ids (F1@rule_run); the
registry scopes them. A facility is named only by facility_id, so the allowlist and the
research-only refusal apply to every one that reads building data. Lists are top-level, worst
first where that means something, so a row cap keeps the rows that matter.
Pages (0.111, #299). A list-returning capability returns one model-sized page of each list
by default, so a typical result stays under about 100 KB (25k tokens) on the wire, and takes
limit (the page size, up to 1,000) and offset (rows to skip) to ask for others; the counts
in data (count, n_points, the KPIs ...) are always of the whole list. A page that is not
the whole list sets truncated and adds a banner such as
'findings' shows rows 1-25 of 159 (worst first): call again with offset=25 for the next page,
or a larger limit (up to 1000). The default pages: facilities 100, points 250, coverage
50 per list (equipment, points, gaps), sensor_trust 100 points, rule_run 25 findings and 25
skipped rules, drift 25 per list, rule_params 25 rules (10 with detail), check_citations
50 sentences; rcx_report returns top_n ranked issues (default the config's, else 8; offset
for the next ones) with only their facts and sections, and the first 20 entries of the fact
index's appendix A (declines and data gaps; limit for more). One offset pages every list of
a result: a short list whose rows are all on the first page is empty on a later one, with a
banner that says so ('cited' is empty on this page: all its 1 row(s) are on the first page).
A capability cuts a list only with camber.capabilities._page.page, which records the notice in
the call; Registry.call turns every recorded notice into a banner and sets truncated, so a
capability cannot cut a list and leave the flag false.
| Name | Arguments | Returns | Cost |
|---|---|---|---|
facilities |
optional limit, offset |
the allowed facilities: id, name, lifecycle state, time zone, first and last stored sample, point and equipment counts | cheap |
points |
facility_id; optional equip, role, equip_class |
the facility's stored points (equipment, class, role), sorted, and their count | cheap |
coverage |
facility_id; optional equips, roles, start, end, group_by (point / equip), min_gap_hours |
per point the samples, first and last sample, interval, coverage (of the whole window, at the point's own interval) and gaps; per equipment the mean and the worst point; the longest gaps | one timestamp scan |
sensor_trust |
facility_id; optional equips, roles, start, end, view (rules / report), min_trust |
per point the trust score, verdict, flags, coverage (of the point's own span on the rule run's time grid, or its equipment's running hours: it can differ from coverage's for one point) and whether it is below the caller's trust floor (ctx.min_trust, or a higher min_trust), least trusted first; the run config's sensor-reference comparisons (references: bias, its sign, RMSE, correlation, samples, overlap, verdict; a passing one too) |
reads every trend |
rule_run |
facility_id; optional rules, params ({rule: {param: value}}), equips, start, end, min_severity |
the findings (finding_id, rule, equipment, severity, summary, caveats, scalar metrics, and conditional_on when an input's configured sensor reference disagrees), worst first; counts per severity; the rules skipped for missing inputs |
long-running |
finding_detail |
facility_id, finding_id; the same optional arguments as the rule_run call |
one finding's full metrics, the parameters it ran with (default, overridden or the rule's built-in fallback), its flagged hours as contiguous time spans (and per named sub-condition; an event rule's events counted apart from the hours) or why there are none (violations_note), and which evidence chart applies; no image |
the same run as rule_run |
rcx_report |
facility_id; optional equips, start, end, top_n, offset, limit |
the RCx report as data: KPIs, the top_n ranked issues (cause, the rules and the finding_ids to pass to finding_detail, cost or why uncosted, hours, confidence and its reasons), the section list and the report's fact index; no file |
long-running |
mv_summary |
facility_id; optional equips |
the M&V findings of the project the caller's run config declares for the facility: baseline fits, savings with uncertainty (in the meter's own units unless the config has a units block, and "for information only" when the baseline fails G14 acceptance; both said in the text), triggers, method proposals, and which declined |
seconds |
drift |
facility_id; optional baseline [start, end], current, families, equips |
drift findings and per-equipment verdicts against a known-good baseline window (fitted for the call, never saved) or the run config's frozen baselines (read, never written), and the equipment not evaluated | long-running |
dataset_search |
optional query, kind, labeled, licence |
catalog metadata: id, title, kind, licence, access, commercial use, fault labels, equipment, download size, what it is good for | cheap |
dataset_entry |
dataset_id |
one catalog entry's metadata: summary, publisher, citation, DOIs, licence and access reason, subsets, suggested rules, known issues and data issues | cheap |
rule_params |
optional rules, query, detail |
the rules and opt-in drift detectors (run_with: rule_run for a rule, drift for a detector, which rule_run refuses), the equipment families each is written for, and their tunable parameters with defaults, units, ranges and (with detail) basis and calibration advice |
cheap |
check_citations |
text, facts; optional per_sentence, detail |
the counts per verdict (supported, unknown_citation, uncited, unsupported_number) of a drafted text's sentences against the facts of earlier results, and the sentences that fail; with detail, every sentence and the text with the unsupported sentences removed |
cheap |
Which config a run uses. rule_run, finding_detail, rcx_report and drift use the
caller's run config when it names the facility -- its rules, parameters, mapping, topology and
report options. ctx.config is one config path or a tuple of them, one per facility (0.111,
296). ctx.config_snapshot, when set (camber mcp sets it), holds (path, config) pairs as
loaded and checked at startup, and the calls read those instead of the files (#308). Without one, a facility ingested from a catalog dataset (ds-...) runs with that
dataset's packaged config template, the one camber datasets config writes, read from the
package (no network, nothing written), with a banner naming the dataset; from_config is then
true. Any other facility gets a store-backed config with every stored equipment class (the
facility's weather equipment, if any, as the shared outdoor-air temperature) and every built-in
rule at its defaults, with a banner saying so. mv_summary, and drift without a baseline,
need a run config with an M&V project or a drift section (a dataset template counts). Every run goes through run_config_for, so it is no-persist and gets the trust
floor. rcx_report also runs the config's drift section, as camber report --layout rcx
does (0.111, #297), so its ranked issues match the command line's. A configured OAT reference
that fetches weather over the network is left out of rcx_report (a CSV reference is kept), and
no prose is generated. A configured sensor reference (report.rcx.references, such as a duct
logger) is compared the way rcx_report compares it in sensor_trust, rule_run and
finding_detail too (0.111, #313). sensor_trust lists every comparison under references
(a passing one too, so a reader can cite that a sensor was checked): bias is the median of
the point minus the reference over their paired samples, so a positive bias means the point
reads high. A comparison at warn or fault makes its point suspect (flag reference_warn /
reference_fault) whatever its trust score, since an offset has no shape in the trend; the
score and below_floor are unchanged, so the check is not declined. Instead every finding
whose rule reads that point on that equipment carries conditional_on (the comparison) in
rule_run and finding_detail, its fact says "conditional on sensor_drift:supply_air_temp
warn ...", and a notice says to treat it as a sensor problem first. The notice counts the
findings on the page returned as "listed" and names the conditional ones on other pages or below
min_severity apart; rule_run's fact C1@rule_run names those (0.111, #320), so the notice's
claim can be cited. In rcx_report such an
issue names it in its fact the same way, and the sensor issue ranks just above the first issue
that is conditional on it, whatever their severities. A reference that cannot be compared (its
equipment has no data for the role, say) is named in a notice of sensor_trust, rule_run and
rcx_report alike, since the findings on that point were not checked against it (0.111, #320).
The trust floor and sensor_trust read one score (0.111, #311). A rule's input is scored
on its equipment's whole run frame -- every role at the run config's resample, over the run's
window, with the shared points merged -- by the same function that scores sensor_trust's
rules view, which reads the frames rule_run reads. So a check is declined exactly when
sensor_trust (same window) puts one of its gated inputs below_floor -- a required input, or
a present input of a group the rule needs one of -- and the decline names each input the rule
reads that is below the floor, optional ones included, with that score ("declined -- untrusted
input(s): co2 (trust 0.14), oa_airflow (trust 0.08)"; 0.111, #318). An optional input below the
floor does not decline a check on its own: the rule runs without trusting it, and
sensor_trust shows its score. The exception is an input that is optional only because the rule
then returns no verdict, which the check is judged on whenever it is there (0.111, #320): the
outdoor-air signal of dcv_verification (OA flow, else the OA damper), the OAT of
outdoor_air_fraction, the wet-bulb source of condenser_water_reset and
cooling_tower_approach (a wet-bulb point, else the OAT and RH), and the fan signal of
night_weekend_setback (the status, else the speed) gate the check like a required input. A trend logged in
bursts covers only its bursts on that grid, so it can be below the floor although every stored
sample is sound.
A selection that matches nothing is not_found (0.111, #296), never an empty answer that
reads as a clean bill of health. An equips / equip id, a roles / role or an equip_class
the facility does not store is not_found in every capability that takes one (points,
coverage, sensor_trust, rule_run, finding_detail, rcx_report, drift, mv_summary).
The message names the unknown values and up to 10 of the stored ones (quoted, one line), and the
error's data has argument, unknown and up to 50 available values. An equips id the
facility stores but the run config runs no rule on (its class is not in the config's
equipment) is not_found too. A start / end window (or a drift baseline / current
period) with no stored sample is not_found for the run capabilities and sensor_trust,
naming the first and last stored sample (data: argument, first, last): nothing can be
judged there. coverage alone answers such a window, with 0% coverage and a notice, because
that is the answer. A selection that exists but has no data (an equipment with no such point,
a window inside the data where some points are missing) is still an answer.
Finding ids. A rule_run finding is <rule>@<equip> (economizer_high_limit@AHU-1), with
#2, #3 ... when a rule gives one equipment more than one finding. finding_detail makes the
same run as rule_run with the arguments given (every rule of it, so a verdict one rule takes
from another reads the same), so pass the same rules, params, equips and window as the
rule_run call. A span in violations is a run of flagged samples with no gap longer than one
and a half intervals. share is of the judged_samples: the samples the rule itself judged
(its own mask: fan-on, occupied, running, both valves shut ...), never the whole window, and
judged_basis names them ("fan-on samples"), so it is the share the rule_run summary states
(#310). A finding that judges named conditions (one per coil, per G36 fault condition, per
failure mode) has the union of their flagged samples in violations and lists each condition
in conditions with its own judged samples; each one is stated as a fact, and those are the
shares its summary states. A drift finding id carries its family (ahu:coil_valve_drift@AHU-1).
Catalog metadata is public. dataset_search and dataset_entry list research-only entries
too, each with its licence, access and a notice (and a banner) that this server refuses any
facility ingested from it. Nothing is fetched or ingested: that is camber datasets, run by a
person.
Built-in prompts
Each prompt renders one user message naming the capabilities to call and in what order, asks
for every sentence to end with the fact ids it rests on, forbids a number no cited fact carries,
says a declined check or a refused call is not a pass, and ends by calling check_citations on
the draft with the facts the calls returned. Argument values are reduced to one short line.
| Name | Arguments | Calls |
|---|---|---|
triage_ahu |
facility_id, equip |
points, sensor_trust, rule_run, finding_detail (the worst findings), check_citations |
rcx_executive_summary |
facility_id |
rcx_report, check_citations |
explain_finding |
facility_id, finding_id |
finding_detail, rule_params, sensor_trust, check_citations |
check_savings_claim |
facility_id; optional claim |
mv_summary, check_citations |
data_quality_review |
facility_id |
coverage (by equipment, then the worst), sensor_trust, check_citations |
compare_equipment |
facility_id, equips (two or more, comma-separated) |
coverage, sensor_trust, rule_run, check_citations |
sensor_trust_review |
facility_id |
sensor_trust (both views), rule_run, check_citations |
plan_site_visit |
facility_id |
rcx_report, sensor_trust, finding_detail, check_citations |
The policy every call follows
Registry.call(name, arguments, ctx) enforces, in order:
- Input validation.
argumentsmust match the input schema, elseinvalid_params(the error'sdata["errors"]lists each problem with its JSON pointer). A whole-number float where the schema asks for an integer (5.0) passes, as JSON Schema 2020-12 says, and the handler gets it as theint(0.111, #308). - Facility allowlist.
ctx.allowed_facilitiesis required; empty means no facility is visible. A capability names a facility only through afacility_idorfacility_idsargument, and one outside the allowlist isnot_allowed, whether it exists or not, so a caller cannot probe for ids. A listing shows only allowed facilities, and a result that names another facility is aninternalerror. - Research-only data is refused. A facility ingested from a dataset the
catalog holds research-only (a non-commercial or no-derivatives licence, or an
open-licence dataset held back for a stated reason) is
refused_licence. There is no override in any adapter:--accept-noncommercialis for the person runningcamber datasets, not for an assistant. The check reads the facility's recorded dataset block (access,redistribution,licence) and, by itsdataset_id, the catalog's current terms. A listing leaves such facilities out and says how many it withheld.
The check fails closed (0.111, #290). A facility named for a catalog dataset (ds-<base> or
ds-<base>-<group>, the names camber datasets ingest gives) is checked against the
catalog by its name too, independent of the registry, so a registry entry that is missing or
records an open licence cannot unlock a research-only dataset. A facility whose licence or
lifecycle cannot be established is also refused_licence, with
data["reason"] == "licence_unverified": every facility while the store's facility registry
is missing (in a store holding ds- facilities), unreadable, empty or malformed, and a ds-
facility the registry has no entry for or that neither the catalog nor its entry records.
While the registry is damaged, every result carries a banner saying so, and the facilities
listing counts such facilities in withheld_unverified. A store of your own data with no
registry is not damaged: its unregistered facilities are served as before.
4. No-persist. The handler runs inside the no-persist scope (#276): a config run folds no
fault history, writes no report file or manifest entry, writes back no store cache and
fetches no weather. See Fault lifecycle.
5. Trust floor. ctx.min_trust (0.5 by default) is the lowest trust_gate.min_trust a config
run gets through run_config_for; a config that asks for more keeps its own. A check whose
input scores below the floor declines with an info finding. rule_run, drift and
rcx_report then carry a banner with the number of declined checks, the rules and the
untrusted inputs, and their summary and first fact give the count (0.111, #297): a declined
check is not a pass.
6. Caps (ctx.caps). A list in the result longer than max_rows (5,000) is cut, with
truncated=True and a banner that says how many rows there were; a result whose JSON --
data, text, facts and banners together (0.111, #299) -- is larger than max_bytes
(2 MB) is too_large; a call that runs longer than max_seconds (120 s) is timeout.
7. Citations. text is checked against facts with camber.agent.verify.check (strict): a
sentence that states a figure no cited fact carries is removed, and a citation of an id the
result has no fact for is removed (its sentence kept), each with a banner that says which
(0.111, #291).
8. Text from the data (0.111, #291). Facility, equipment and point names, classes, units and
metadata come from the data, so they can never pass for the tool's own words. A result's
text, every fact's text, every banner and every error message are one line: newlines and
tabs become spaces, and control characters (C0 / C1: ESC sequences, BEL, NUL), bidi overrides
and isolates, zero-width and other invisible characters are removed. A line that starts with
NOTICE: in the MCP summary is therefore always a banner, and a text that would itself start
with NOTICE reads Summary: NOTICE .... A handler names a data value with
camber.capabilities._sanitize.name(), which quotes it (equipment "AHU-2"), writes a
fact-id bracket inside it as (F1@rule_run), defangs markup (0.111, #307: < and > read
‹ and ›, a markdown link [x](url) reads [x] (url), and a URL https[:]//... or
www[.]..., so no client renders a tag or a link) and cuts it at 100 characters (an ellipsis and
six hex digits of a hash keep two cut names apart); a quoted value is one word to the citation
check, so a sentence break or a number inside a name is never "removed". In text, only the
handler's own fact ids are citations: any other [F1@x] reads (F1@x), and a fact's text
holds no citation at all. data keeps the stored values exactly (so an id can be passed
back), except that any string in data or facts longer than 2,000 characters is cut the
same way, so one huge name does not make a whole facility too_large; a result with a cut
value has truncated set and a notice saying how many were cut (0.111, #320). A capability's
own output named in Capability.own_output is never cut: check_citations returns the
caller's verified_text and sentences whole. Schema patterns use
ECMA-262's $ (the end of the string; Python's $ also matches before a final newline). New
equipment ids, classes and roles with such characters are refused at ingest (see
SCALE.md).
9. Store errors. A read of stored data that fails -- a truncated, empty or non-Parquet part
file, an unreadable facility directory, or a part file a concurrent ingest replaced between
listing and opening it (the store retries such a read once first) -- or any other OSError
is unavailable, never internal (0.111, #301). So is a call that a writer replacing or
removing files of a facility it reads overlapped -- a forced re-ingest's partition swap, a
prune, a removal -- whatever the handler answered (0.111, #306): each such writer marks the
facility busy in <store>/_fence/<facility_id> and gives it a fresh token when done, and a
call checks the fences of the facilities it reads (those its arguments name, else every
allowed one) before and after it runs; it waits up to a second for a swap in progress. A
removal, and a first ingest, keep the mark until the facility's registry entry is gone or
written too, and a mark left by a writer that was killed (its process gone, or its process id
since reused by another process) blocks no call; the next writer to take the store lock
clears it (0.111, #317). The exception is a writer killed between the two renames of its
swap: until the next writer to take the store lock finishes that swap, the facility's calls
stay unavailable with "retryable": true (0.111, #321). Its
data is {"retryable": bool}, plus "facility_id" when the read was of one facility the
caller may see: retryable is true for a file that went missing or a concurrent replace
(retry the call) and false for a damaged or unreadable one (a
retry will not help until it is repaired or the facility re-ingested). The message names no
path and no facility outside the allowlist. One facility's damaged file does not affect
another facility's calls, and the facilities listing still lists it, with
"unavailable": {"retryable": ...} and a null start / end. A call reads only the
facilities it is about (0.111, #293): while the store's point catalog is stale (after a
write; a call never writes it back), a call on one facility scans that facility's partition
only, and facilities reads each listed facility's catalog entry and its files' statistics,
never its rows -- so neither the cost of a call nor a damaged file depends on the rest of the
store.
10. No leaks. Any other exception becomes internal, and no error message -- nor the data
of an error a handler raises, nor a result's text or banners -- holds an absolute local
path. The known roots (the store, the config, its directory and the stores or folders it
reads, the dataset data directory, home, temp, the working directory and the Python prefix)
are replaced by <path> first, in every spelling (quoted, repr, JSON, either Unicode
form), with the rest of the path after them; then a pattern takes any other absolute path
(a quoted one whole, spaces and commas included). Words either side of a slash
("non-commercial / no-derivatives", kBtu/ft2) are not paths; after a redacted root, the
path goes on across a space while the next word has a separator in it
(<path>/John Smith/My Drive/x is one path; a last word with no separator cannot be told
from the sentence, so quote paths in messages). The JSON pointers of an
invalid_params refusal by the input schema (/facility_id: ...) are left as they are.
A name from the data (a quoted value, and the stored ids an error's data lists) is not a
path of this machine, so only the known roots come out of it, never the pattern (0.111,
#307): a Niagara ORD such as "/Drivers/BacnetNetwork/AHU_1" or "slot:/Config/AHU-6"
reads as it is, as in the facts. A value the caller sent, echoed in an error, is redacted
like the tool's own words, so an error never tells the caller which paths exist.
In-process, the timeout cannot stop a handler. The handler runs in a worker thread; Python
cannot kill a thread, so on a timeout the call returns timeout at once while the handler
finishes in the background and its result is thrown away. Handlers are read-only and run
no-persist, so a late finish changes nothing, but it holds its CPU and memory until then.
camber mcp therefore runs every call in a worker process (camber.mcp.WorkerPool, 0.111) that
calls Registry.call exactly as here and is killed on a timeout, a cancel or a disconnect; a
transport that serves untrusted or many callers should do the same.
Adding a capability
Adding one is a one-file job: a module camber/capabilities/_cap_<name>.py that defines
CAPABILITIES (and, optionally, PROMPTS). default_registry() imports every _cap_* module in
name order.
from camber.capabilities import Capability, CapabilityResult
def equipment_count(ctx, arguments):
# arguments is valid, and its facility is allowed and licence-clean
...
return CapabilityResult(data={...}, text="... [F1@equipment].", facts=[...])
CAPABILITIES = (
Capability(
name="equipment_count",
title="Equipment count",
description="What it does, when to call it and what it returns (for a tool picker).",
input_schema={"type": "object", "required": ["facility_id"], "properties": {...}},
output_schema={"type": "object", "properties": {...}},
handler=equipment_count,
),
)
A handler names facilities only through facility_id / facility_ids, keeps a listing to
ctx.allowed_facilities and leaves out research-only facilities (research_only(meta)), calls
run_config_for(ctx) for any config run, puts every figure its text states into a fact it
cites, and writes every name from the data in its text, facts and banners with
camber.capabilities._sanitize.name(). The schemas use a subset of JSON Schema 2020-12: type, properties, required,
additionalProperties, enum, const, items, minItems / maxItems, minimum /
maximum (and the exclusive forms), minLength / maxLength and pattern. default is an
annotation and is not applied. Registering a schema with any other keyword is a ValueError, so a
constraint is never declared without being enforced.