Public information
API reference
How to connect your own software to BRON. Keys can be limited and revoked, and requests go through the same access checks the product uses. Where code enforces a count or a limit, this reference names the endpoint or constant that holds it, rather than repeating a number that could go out of date.
Last updated September 15, 2026.
On this page
Read/write access to a firm's litigation matters for external applications, plus an MCP server so an AI agent can reach the same data through the same gates.
- Base URL —
https://<your-host>/api/v1 - Machine-readable description —
GET /api/v1/openapi.json(no auth; shapes only, never data) - MCP endpoint —
/mcp(POSTfor JSON-RPC,GETfor the server-initiated event stream,DELETEto tear a session down) - Manage keys and webhooks — Settings → API & Webhooks in the app
Every payload carries a schema_version (currently 1.0), a generated_at timestamp, and the
case_id it belongs to. Pin the schema_version in your consumer — see
§6 Versioning for what that number does and does not promise.
1. Authentication & trust model
Every request presents a brk_ key: brk_ followed by 48 hex characters. Send it either way —
they are equivalent:
curl -H "X-API-Key: brk_..." https://<host>/api/v1/matters
curl -H "Authorization: Bearer brk_..." https://<host>/api/v1/matters
Keys are created in Settings → API & Webhooks. The plaintext is shown once, at creation — we store only a SHA-256 hash and a short prefix, so a lost key cannot be recovered, only replaced. Revoking a key takes effect on its very next request.
A brk_ key is a first-party firm credential — treat it like a password
A brk_ key is not a client-facing or public token. It is a first-party firm credential: it
acts as its owner and carries the owner's own, full access to the firm's litigation record. By
design that includes attorney-grade, privileged, and work-product material — most visibly the
knowledge graph, which on kg:read returns the full attorney view (privileged and
work-product-derived nodes, and their verbatim provenance quotes) by default. The narrowed,
client-producible view is opt-in per request via client_safe=1; it is never the default — unless
the key was minted in the client_safe class, which forces it (see Sensitivity classes).
Because a key sees what its owner sees, the security control is the firm deciding who holds a key. Consequently:
- Issue keys only to trusted first-party integrations you would trust with the owner's own
logged-in access. Never hand a
brk_key to a client, a third party, or an untrusted tool, and never embed one in client-side or public code. - Treat the plaintext like a password. Store it in a secrets manager, rotate it, and revoke it (Settings → API & Webhooks) the moment it may be exposed — revocation is effective on the next request.
- Narrow at mint time where you can. Grant the least scope that does the job, and set a matter allow-list to bound which matters the key can reach (both described below).
Two firm-wide controls still apply to every key regardless of the above: the key owner's ethical walls (ABA 1.10) subtract screened matters server-side, and the key's optional matter allow-list can only ever narrow the reachable set. See What a key can reach.
Sensitivity classes
Every key carries a sensitivity class, chosen at creation, that bounds what the key can be shown regardless of what a request asks for:
| Class | What it sees | Who can mint it |
|---|---|---|
attorney (default) | Everything described above, including the full attorney-grade knowledge graph with privileged provenance | A firm administrator |
client_safe | The same matters, but the knowledge-graph lanes are forced to the privilege-gated client view — client_safe=1 is applied for you and cannot be turned off | Any firm staff member |
attorney is the default, so existing keys are unchanged and an integration that does not ask for a
class gets the historical behaviour. A client_safe key still receives the honest flag
client_safe_forced_by_key: true on graph payloads, so a consumer can tell that its credential
narrowed the answer rather than its request.
Choose client_safe for anything you would not hand the owner's own logged-in session — a
dashboard, a demo, an agent you are still evaluating. It is a floor at the credential, which is the
one place a per-request flag can never be forgotten.
Create with sensitivity on POST /api-keys:
{ "name": "Acme graph mirror", "scopes": ["kg:read"], "sensitivity": "client_safe" }
What a key can reach
A key never carries its own permissions. It acts as its owner, and every request resolves the reachable matters server-side:
the OWNER's own matters
∪ every matter belonging to the owner's FIRM (the firm partition)
minus matters the owner is screened from by an ethical wall (ABA 1.10)
∩ the key's optional matter allow-list
Two consequences worth being precise about, because they cut in opposite directions:
- Wider than the owner's personal matters. The firm partition means a key reaches the firm's unscreened matters, not only the ones its owner created. Use the matter allow-list when you want a key bound to a specific engagement.
- Narrower than the owner's in-app view. Matters shared with the owner individually (the
shared_withlist, which is keyed by email address) are not reachable on this lane: a key resolves an owner id, never an email, so that branch never matches. If an integration needs such a matter, add it through firm membership rather than an individual share.
The allow-list can only ever narrow that set. A case_id in your request is checked against the
result — it is a filter, never a grant. An id you cannot reach returns 404, identical to an id that
does not exist, so the API cannot be used to probe for matters.
Two things are never served on an API key, at any scope: documents classified privileged or
work_product, and (in the client-safe graph lane) nodes derived solely from them.
Rate limits
The bucket is keyed by (surface, lane, key). One key used from both /api/v1 and /mcp spends
from two separate budgets, and neither can drain the other. The surface comes first in the bucket
key, so a firm's own agent on /mcp never competes for tokens with a third-party integration holding
the same credential.
Two lanes exist on each surface. rateLaneForScope() decides which: retrieval:query and
drafting:check bill to expensive, everything else to read. The surface changes the size of
the budget, never which lane a scope spends from.
Read the numbers off the wire, not off this page. The enforced values are the exported constants
API_KEY_SURFACE_RATE_LIMITS (rest → API_KEY_RATE_LIMITS, mcp → MCP_API_KEY_RATE_LIMITS) in
backend/src/lib/apiKeys.ts, and RateLimit-Policy on every authenticated response advertises both
lanes of your surface from those same constants. The REST budget is the smaller of the two on
purpose: a REST caller is a third party, while an MCP caller is the firm's own AI reading the firm's
own record.
Every authenticated response carries the budget it just spent from:
| Header | Says |
|---|---|
RateLimit-Limit / -Remaining / -Reset | The (surface, lane) this request spent from. -Reset is seconds until the lane refills to its full limit, not the wait for one token |
RateLimit-Policy | Both lanes of this surface, named "<surface>-<lane>" with q= the limit and w=60 — for example "rest-read", "mcp-expensive". A name that carried only the lane could not tell you which ceiling applied |
X-RateLimit-Enforcement | per-process, plus the pm2 instance when one is known |
X-RateLimit-Ip-* | The per-IP backstop's own numbers, relabelled so they can never be mistaken for your key's budget |
Budgets refill continuously and burst up to the full limit. Over the limit you get 429 with a
Retry-After header in seconds; on MCP the same refusal arrives as a tool error carrying the same
delay. Both lanes are counted independently for the same key, and no key's traffic consumes another
key's budget — but the IP backstop below is shared, so that is not the same as saying nobody else can
throttle you.
The stated limit is a floor, not a ceiling. The limiter is in-process and the server runs several worker processes behind one port, so a client spread across workers can observe several times the number in the header. Treat it as the bound you can rely on being enforced, and do not design a consumer that depends on getting more.
X-RateLimit-Enforcementsays this on every response.
Per key is not your only ceiling. Two per-IP backstops sit in front of the key budget, so a 429
can arrive with your key's own budget untouched:
| Backstop | Applies to | Default |
|---|---|---|
| Server-wide | Every request except /health and /mcp, including the unauthenticated GET /openapi.json | 1000 requests / 15 min |
| Drafting | POST /drafting/verify-citations, /drafting/table-of-authorities, /drafting/pre-file-check — the three that spend on a model. /drafting/link-authorities is deterministic and exempt | 40 requests / 15 min |
POST /mcp is exempt from the server-wide backstop (skipPaths in backend/src/index.ts).
Hosted MCP connectors reach the server through shared egress addresses, so an IP budget there would
become a cross-firm cap; the per-key MCP budget is the real control on that surface.
The backstops are deployment-tunable and, being per IP, are shared: several keys behind one NAT
draw on one budget, and a noisy neighbour can spend it. All three limiters set Retry-After, so the
handling rule stays simple — honour Retry-After, and never read a 429 as proof that your own
key's budget is spent.
Reads are audited
Graph exports (/matters/:id/knowledge-graph, /firm-graph), retrieval queries and document
dereferences each write a row to the firm's tamper-evident audit log — actor, key id, matter, lane,
and result size. Every authenticated request is also recorded in a per-key usage ledger. This is
visible to the firm, not to the API consumer.
Scopes
| Scope | Grants |
|---|---|
read | Matter records, deadlines, and every granular read scope below |
write | Every scope in this table — but never a non-inheritable scope (below) |
kg:read | Knowledge graph + firm graph, including the JSON-LD / GraphML exports |
retrieval:query | Hybrid RAG retrieval |
docs:read | Provenance dereference (GET /documents/:id) |
drafting:check | The four stateless drafting checks (/drafting/*) the editor add-ins use |
tasks:read | The proactive tranche: the triaged agenda, the Matter Mind, the daily brief |
analysis:read | The firm's own judgment layer: valuation, strategy and claim analysis, and the calibration and outcome ledgers. Separated from read so a firm can hand an integration the record and the docket without also handing it the win/loss history |
The coarse scopes remain supersets: write > read > {kg:read, retrieval:query, docs:read, drafting:check, tasks:read, analysis:read}. A key minted before the granular scopes existed keeps working
unchanged. Grant the narrowest scope that does the job — a key that only needs the graph should carry
kg:read alone.
Non-inheritable scopes. Some future scopes will never be satisfied by read or write, however
old the key: record:propose, checks:write, record:export and events:read. They propose changes
to the factual record, write check results, export the record, or stream its events, so a firm must
grant them by name to a key minted knowing what it does. None is available to mint yet; they are
reserved so that adding one cannot silently extend every existing write key
(NON_INHERITABLE_SCOPES, backend/src/lib/apiKeys.ts).
drafting:check is classed as a read scope even though its endpoints are POST: they take
document text you already hold, return an analysis, and neither read anything out of the firm nor
write anything into it.
Errors
| Status | Meaning |
|---|---|
400 | Malformed request (missing content, query over 2000 chars, unsupported format) |
401 | Missing, invalid, or revoked key |
403 | The key lacks the scope this endpoint requires |
404 | Not found or not reachable by this key — deliberately indistinguishable |
409 | An Idempotency-Key already names a row with different content. The original stands; nothing was written |
413 | Document text over 500,000 characters (drafting checks) |
429 | Rate limited — either this key's per-minute lane budget or a per-IP backstop; honour Retry-After |
502 | An upstream check (citation verification, ToA, pre-file) could not complete — not a pass |
503 | A read faulted. The knowledge graph — not an empty graph; retry. Every delegated read answers its own <collection>_read_unavailable code the same way, carrying retryable: true and verification_status: "unknown". Also: a document dereference whose release could not be bound (dereference_binding_unavailable) — not a "no bytes" answer; retry |
Error bodies are {"detail": "..."}. The 502 and 503 cases matter: this API never converts a
failed check into a clean verdict or a failed read into an empty result.
2. Endpoints
GET /api/v1/openapi.json is the inventory; this section is the guide. The description is
generated from one descriptor per operation in backend/src/lib/openapiSpec.ts, and
publicApi.invariants.test.ts set-compares it against the routes actually registered — its
"pending" escape hatch is empty, so a route that is not in the spec does not exist. Read the spec for
the operation list, the parameters and the response shapes; read the prose below for the rules that a
schema cannot state. The one lane deliberately outside that spec is the external-agent grant surface,
documented under External-agent grants.
The operations span the matter record, the docket, the knowledge graph, retrieval, documents, discovery, money (invoices, time, trust, fee arrangements), valuation, trial preparation, the firm desk, formation requests and the four drafting checks. Every one of them is a URL over the same handler its MCP twin runs — same funnel, same client-safe floor, same row caps, same audit row — so the two surfaces cannot drift into two different answers.
Pagination
List payloads take limit and cursor. limit is 1 to PUBLIC_API_PAGE_MAX, default
PUBLIC_API_PAGE_DEFAULT — both exported from backend/src/lib/openapiSpec.ts and imported by the
router, so the enforced bound and the published one cannot drift. GET /matters and
GET /matters/:id/deadlines predate pagination: with no limit and no cursor they return the
whole list, as before.
A cursor is the opaque page.next_cursor from the previous response. It is signed and bound to the
endpoint, to this key's own scope, and to the exact set it was minted over, so it cannot be edited to
step outside the key's access, carried to another endpoint, or reused after that set changes. Any of
those answers 400 — start again from the first page rather than trusting a page that moved under
you.
Every paged payload carries a page envelope, and two of its fields have to be read together:
has_moresays whether another page exists.source_truncatedsays whether the read behind the collection stopped at its own row cap before pagination began.has_more: falsewithsource_truncated: truemeans there are no more pages and there are more rows:totalis the size of what was read, not of the record, and paging cannot reach past a capped read. Narrow the query instead. The attestation'sverification.notesnames the cap.
Reading has_more alone is how a capped calendar gets mistaken for the whole one.
Matters
GET /matters — scope read
Every matter this key can reach.
{ "matters": [ { "id": "…", "name": "Acme v. Northbeam", "case_number": "1:24-cv-01234",
"status": "active", "phase": "discovery", "case_type": "commercial",
"date_filed": "2024-03-11", "date_closed": null } ] }
GET /matters/:id — scope read
One matter, same shape as an element above.
GET /matters/:id/deadlines — scope read
{ "deadlines": [ { "id": "…", "title": "Expert disclosures", "due_date": "2026-09-14",
"deadline_type": "court_rule", "created_at": "2026-07-01T14:02:11Z" } ] }
Ordered by due_date ascending.
The proactive tranche — scope tasks:read
Full detail, including every field and the refusal rules, is in
docs/PROACTIVE_API.md. In short:
GET /matters/:id/tasks — scope tasks:read
The matter's triaged agenda — the same engine behind the firm's own desk, not the raw task
table. Internal plumbing and work-surface findings are excluded from the attention lanes, routine
volume is rolled up, and a task a human snoozed stays snoozed. Returns {lead, today[], this_week[], lanes{on_the_clock, judgment, housekeeping, quiet}, findings[], counts{}, open_task_count, truncated}. A read fault is 503, never a 200 with an empty agenda.
GET /matters/:id/mind — scope tasks:read
The Matter Mind: the standing brief for the matter plus its ranked agenda and lead surface.
{available, brief_md, agenda[], lead_surface, author, authored_by_model, computed_at}. When a model
polished the prose, authored_by_model is true — treat it as summary, not record. No brief yet
returns available: false, which means the absence of a brief, not an empty matter.
GET /brief — scope tasks:read
The key owner's most recent daily brief, verbatim. {available, brief_md, posted_at}.
All three refuse a client_safe-class key with 403 rather than narrowing it: what a lawyer
should do next is attorney work product and has no safe subset. /brief additionally refuses a key
carrying a matter allow-list — it is composed across the whole desk, and the prose cannot be narrowed
after it is written.
(All three carry openapi.json entries, tagged proactive. Field-level detail is in
docs/PROACTIVE_API.md.)
POST /matters/:id/notes — scope write
POST /api/v1/matters/<id>/notes
Idempotency-Key: 4f1c8e2a-…
{ "title": "Sync from Acme CRM", "content": "Client confirmed the March invoice dispute." }
content is required; title defaults to "API note". The note is recorded as privileged work
product and attributed to the key owner. Returns 201 with {id, title, created_at}.
Send an Idempotency-Key. With one, a retry of the same request answers 200 with the original
note and replayed: true, and writes nothing. Reusing a key for different content is 409: the
original stands. The key is at most 128 characters; a longer one is 400. Row identity is derived
from the key, so the same key converges on the same row rather than being deduplicated after the
fact. The MCP twin add_case_note requires its idempotency_key and derives the identical
identity, so a caller that retries across both surfaces still lands on one note.
This is the sole record mutation on the REST surface, and it has no external effect: it sends nothing, changes no deadline, and moves no funds.
Knowledge graph
GET /matters/:id/knowledge-graph — scope kg:read
| Query param | Values | Effect |
|---|---|---|
client_safe | 1 | Narrow to the producible client view — apply the client-portal privilege gate (fail-closed). Omitted / not 1 returns the full attorney view (see below). |
format | json (default), jsonld, graphml | Export format |
Default is the full attorney-grade graph. Without client_safe=1 this returns privileged and
work-product-derived nodes and their verbatim provenance quotes — the same graph the key owner sees
in-app. That is intentional: a brk_ key is a first-party firm credential (see
§1 trust model). Pass
client_safe=1 for the narrowed, client-producible view.
Unless the key is client_safe-class, in which case the narrowed view is applied for you and the
response carries client_safe: true, client_safe_forced_by_key: true. The client_safe query
parameter can only ever narrow — it cannot widen a key past its class.
A read failure returns 503, never 200 with an empty graph: downstream, an empty export is
indistinguishable from a matter that genuinely has no graph, and that difference is worth a retry.
{ "schema_version": "1.0", "generated_at": "2026-07-22T10:14:02Z", "case_id": "…",
"client_safe": false,
"entities": [ { "id": "…", "name": "Northbeam Capital", "entityType": "organization",
"confidence": 0.91, "attributes": {},
"provenance": [ { "sourceType": "evidence_document", "sourceId": "…" } ] } ],
"relationships": [ { "id": "…", "source": "Northbeam Capital", "target": "Master Services Agreement",
"relationship": "party_to", "confidence": 0.88 } ],
"entity_count": 326, "relationship_count": 213 }
Each entity carries provenance — the sources it was extracted from. Dereference a
provenance[].sourceId through GET /documents/:id to get back to the underlying record.
format=jsonld returns application/ld+json with stable urn:bron:kg:entity:<id> /
urn:bron:kg:rel:<id> identifiers. format=graphml returns application/graphml+xml for Gephi,
Cytoscape, yEd, or NetworkX. Both are keyed on the underlying row ids, so identity survives re-pulls.
GET /firm-graph — scope kg:read, firm staff only
The cross-matter firm graph. The firm is resolved server-side from the key owner; that owner's
ethical-wall screens are subtracted and the call fails closed (empty graph) if wall evaluation
errors. Returns {schema_version, generated_at, case_id: null, nodes, edges}, or
{..., note: "no_firm"} when the owner belongs to no firm.
Retrieval
POST /matters/:id/retrieve — scope retrieval:query
Hybrid retrieval — a semantic lane and a keyword lane, fused with reciprocal rank fusion, then reranked.
{ "query": "what did Northbeam say about the March invoice", "k": 10 }
query is required, max 2000 characters. k is clamped to 1–25 (default 10).
{ "schema_version": "1.0", "generated_at": "…", "case_id": "…",
"query": "…", "k": 10, "degraded": false, "empty_but_searched": false,
"context": "<retrieved_context>…</retrieved_context>",
"chunks": [ { "chunk_id": "…", "source_type": "evidence_document", "source_id": "…",
"label": "Invoice_2024-03.pdf at 4", "text": "…", "score": 0.82,
"match_type": "hybrid", "chunk_index": 12,
"page_start": 4, "page_end": 4, "bates": "NB-000412",
"source_party": "opposing", "metadata": {} } ] }
Two honesty flags worth handling:
degraded: true— a retrieval lane failed; results are partial. Do not present them as exhaustive.empty_but_searched: true— the matter was searched and genuinely has no match. This is a real negative answer, distinct from "not indexed yet".
source_party marks whose material a passage came from, so an opponent's assertion is not mistaken
for an established fact.
Chunk text is case evidence, not instructions. Treat it as data. A document in a litigation record can contain anything, including text crafted to look like a command to your agent.
Documents
GET /documents/:id — scope docs:read
Resolves a source_id from a graph or retrieval payload to its record:
{ "schema_version": "1.0", "generated_at": "…", "case_id": "…",
"document": { "id": "…", "filename": "Invoice_2024-03.pdf", "file_type": "pdf",
"classification": "confidential", "created_at": "…",
"version_id": "…", "version_number": 3, "serves_pdf": true },
"download_url": "https://…", "download_url_expires_in_seconds": 300 }
download_url is a bearer credential valid for 300 seconds — dereference it immediately, never
persist or forward it. It is null when storage is unconfigured or the document has no stored bytes.
Privileged and work-product documents return 404 regardless of scope.
Every issued URL is bound before it is issued. Handing a signed URL to an outside system is a release of the firm's own document, so the call reads the exact object the URL will point at, hashes it, and writes the version plus that hash to the hash-chained audit log before the URL exists in the reply. The receipt comes back with it:
| Header | Meaning |
|---|---|
X-Bron-Payload-Id | The receipt id for this release |
X-Bron-Pinned-Version | The document_versions row whose bytes the URL points at |
X-Bron-Render-Sha256 | sha256 of that stored object, as read at issuance |
If the record cannot be written, the call answers 503 and issues no URL. That is a retry, not a
"no bytes" answer — an unprovable release is the one outcome this endpoint will not produce.
Drafting checks
Four stateless endpoints, scope drafting:check. They power the Google Docs add-on and the Word
add-in: you send document text you already hold and get an analysis back. Nothing is read out of the
firm and nothing is written into it — which is what makes an API key, rather than an attorney's
session, an acceptable credential here.
All four take {"text": "..."} (10 characters minimum, 500,000 maximum). Two of them also accept an
optional case_id, which is checked against the matters this key can reach — naming a matter can
narrow or enrich the check, never widen the key's access.
| Endpoint | Body | Returns |
|---|---|---|
POST /drafting/verify-citations | text, optional case_id | The citation-gate report: every citation extracted, its verification status, holding check, and whether the document is blocking |
POST /drafting/table-of-authorities | text | {toa, rendered} — the grouped Table of Authorities plus a rendered text block |
POST /drafting/link-authorities | text | {links, total} — each citation's character span and a resolved external URL (deterministic; no model spend) |
POST /drafting/pre-file-check | text, optional case_id, optional doc_type | The court-compliance checklist, including a server-computed citation verdict |
Two failure rules are load-bearing, because a false all-clear on a filing is the one outcome this API never ships:
- A verifier or checklist failure returns
502. It is never reported as a pass. - In
pre-file-check, a citation verdict that could not be computed renders as a block ("did not complete"), not as a clear.
The same libraries back the in-app routes, so an add-in can never get a different verdict than the app on identical text.
External-agent grants
A brk_ key is a standing credential. A grant is the opposite: a firm administrator hands an
outside agent one narrow, expiring, single-use capability on one matter, bound to one key. It is a
narrowing, never a widening — a grant can only ever let a key ask for something it already has the
scope to do.
This lane is not in openapi.json. It is mounted separately from the described API
(backend/src/routes/agentGrants.ts), so the spec's route census does not reach it. Read this
section, not the spec, for it.
The grant token is bag_ followed by 48 hex characters, presented in an x-agent-grant header
alongside the ordinary Authorization: Bearer brk_…. Like the API key, it is stored only as a
SHA-256 hash and shown once.
The human lane (/agent-grants, Supabase session, never an API key — a brk_ key carries no
JWT and gets 401 before any handler runs):
| Route | Who |
|---|---|
POST /agent-grants | A firm administrator of the matter's own firm who also personally reaches that matter through ensureCaseAccess. Returns the plaintext token once |
GET /agent-grants | The firm's grants, firm administrator |
POST /agent-grants/:id/revoke | Firm administrator, or the grant's issuer |
The agent lane (/api/v1/agent-grants, brk_ key plus the grant header):
GET /api/v1/agent-grants/self?case_id=… — scope read
Evaluates the grant and performs nothing, spends nothing. It exists so an agent can tell "my
grant is spent" from "my grant does not reach that matter" without burning it to find out. Returns
the grant's status, capabilities and expiry, plus a per-capability verdict with a reason for each
refusal, and spent_by_this_call: false. A capability reported with consumes_grant: true is a
statement about what exercising it would cost, not about what this call did.
POST /api/v1/agent-grants/exercise — scope read at the route, plus the capability's own
{ "capability": "propose", "case_id": "…", "action": "create_deadline",
"args": { "title": "Answer due", "due_date": "2026-08-14" },
"reason": "The complaint was served on 2026-07-24." }
The capability vocabulary is closed at read, upload, propose — enforced at mint, again at
exercise, and by a database CHECK underneath both. A grant cannot name "mint a grant", so that
refusal is by construction rather than by list. Grants default to a 24-hour life, bounded at 60
seconds and 7 days, and may be pinned to at most 100 named object keys.
Five things are re-derived live on every exercise, and a grant is refused if any of them has moved:
- The presenting key's own reach, through the same owner-scope funnel every other endpoint uses.
- The issuer's current reach. Authority delegated yesterday by someone since screened off the
matter is authority that no longer exists — a grant is a pointer to a person's access, not a copy
of it. That refusal names itself
issuer_authority_withdrawn. - The grantee binding. A grant names exactly one key; presented with any other it is refused, so a leaked grant token on its own is inert.
- The key's own scope.
readneedsread;proposeanduploadneedwrite. The grant says what may be asked for; the credential still says what it may do. - The credential class. A
client_safekey is refused outright — delegated firm capability is not a client-facing one.
Then, and only then, the grant's own lifecycle, capability set, firm/matter/object scope and non-replayability are judged.
Non-replayability is adjudicated by the database, as a conditional update whose matched-row count
is the verdict, not by a read-then-write. propose always consumes. Every refusal says whether the
grant was spent; where a failure lands after consumption the answer says spent: true rather than
flattening to a reassuring false, and where completion state cannot be confirmed it says
"unknown".
A grant cannot carry a human's approval. A presented confirmation_token, confirmation,
approval_nonce, approver_id or x-confirmation-token header refuses the whole request with
inherited_confirmation. propose stays propose: it writes a row into the same approvals inbox the
firm's own assistant writes to, capped at the suggest rung, and executes nothing.
upload is refused while no malware scanner is registered on the deployment (503, and the
grant is not spent). An unarmed deployment leaves new objects unenrolled and calls them
scanned:false — it never calls them clean — so accepting an external upload in that posture would
open an unscanned ingress. When it is armed, this route runs the same two-phase composition the MCP
tools begin_upload_session and complete_upload expose, rather than growing a second upload lane.
3. Webhooks
Configure subscriptions in Settings → API & Webhooks. A subscription has a target URL, a list of
event types, and a secret (whsec_…, shown once at creation).
| Event | Fires when |
|---|---|
matter.resolved | A matter is marked resolved (settlement, verdict, dismissal) |
hearing.debriefed | A hearing debrief is recorded |
document.ingested | A document finishes ingest and is retrievable |
kg.updated | A knowledge-graph rebuild stored a new graph |
record.element_turned | An element's proof state turned: it became contested, was held sufficient, or a support/adverse rung moved (pointer-only: ids and rung labels) |
record.position_signed | A position was attested on the record: a fact posture, a sufficiency holding, a definition verification, a conflict resolution or a milestone (pointer-only). The name is a kept identifier: an attestation is recorded server-side under the attorney's account and session method and chained, not cryptographically signed with a key the attorney holds |
record.conflict_resolved | An attorney's resolution of a conflict in a matter's factual record was recorded and the conflict's state moved (not sent for a re-open). Ids, class, basis and new state only -- never either side's words |
record.pr_merged | Every element of a record pull request landed and the PR is marked merged (ids and counts only) |
webhook.test | You pressed Send test |
Delivery, retries, and dead letters
An event is queued durably the moment it fires and delivered by a background drain, so it is not lost if your endpoint is redeploying or briefly unreachable. Each attempt has an 8-second timeout.
- A failed attempt is retried with exponential backoff (≈30s, doubling, capped at 1h) up to 10 attempts, after which the delivery is dead-lettered — it stops retrying and is recorded with its last error. Inspect and re-send from Settings → API & Webhooks.
- Retryable: 5xx, 408, 429, timeouts, connection errors. Not retried: other 4xx (a 400 or 410 will not become acceptable if we send the identical bytes again), and a subscription that has been deleted or deactivated.
- A successful delivery resets the failure count.
- Delivery never blocks the underlying action.
Targets are SSRF-guarded — internal, loopback, link-local, and private addresses are rejected at creation and re-validated on every attempt, with the socket pinned to the vetted IP and every redirect hop re-checked.
Request shape
POST <your target URL>
Content-Type: application/json
X-BRON-Event: kg.updated
X-BRON-Timestamp: 1790000000 # unix seconds when THIS attempt was signed
X-BRON-Signature-V2: t=1790000000,v1=<hex> # verify this one
X-BRON-Signature: sha256=<hex> # legacy, body-only, NOT replay-resistant
X-BRON-Delivery-Id: 9f2c… # stable across retries — dedupe on this
X-BRON-Attempt: 2 # 1 on the first try
Be idempotent, and here is what to key on. Because delivery is at-least-once, the same delivery
can arrive more than once — a retry after your handler succeeded but your 200 never reached us, or a
replay you triggered from Settings. Every attempt at a given delivery carries the same
X-BRON-Delivery-Id and byte-identical body; record the id and ignore one you have already
processed. Each attempt is signed when it is sent, so a retry or a Settings replay carries a fresh
X-BRON-Timestamp and a different X-BRON-Signature-V2 (the legacy X-BRON-Signature is identical,
because the body is). Dedupe on the delivery id, never on a signature. X-BRON-Attempt is informational only — never treat
attempt 1 as "definitely new".
{ "event": "kg.updated",
"sent_at": "2026-07-22T10:14:02Z",
"data": { "event": "kg.updated", "case_id": "…", "schema_version": "1.0",
"occurred_at": "2026-07-22T10:14:01Z" } }
document.ingested adds data.document_id.
Graph-sync events carry pointers, not content — ids only, no document text, no entity names, no PII. A webhook target is an unauthenticated-at-rest URL, so the event tells you what changed and you come back through the authenticated API to read it.
Verifying the signature
Verify X-BRON-Signature-V2. It is HMAC-SHA256, keyed with the subscription secret, over the
string t + "." + rawBody, where t is the unix-seconds timestamp carried inside the header itself:
X-BRON-Signature-V2: t=<unix seconds>,v1=<hex HMAC(secret, t + "." + rawBody)>
- Parse
tandv1out of the header (taketfrom here, not fromX-BRON-Timestamp). - Reject if
|now - t| > 300seconds. This is what stops a captured delivery being re-sent to you later; without it, the signature proves who sent the bytes but not when. - Compute the HMAC over the raw request body bytes (verify before parsing), and compare in constant time.
X-BRON-Signature: sha256=<hex> (HMAC over the body alone) is still sent for existing consumers. It
contains no time, so a copy of an old delivery verifies against it forever. Move to V2. The
reference implementation is backend/src/lib/webhookSignature.ts (verifyWebhookSignatureV2).
const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
function verifyBronWebhook(rawBody, signatureV2Header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(
String(signatureV2Header || "").split(",").map((p) => p.trim().split("=", 2)),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(nowSeconds - t) > TOLERANCE_SECONDS) return false; // stale or replayed
const expected = crypto.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${t}.`), Buffer.from(rawBody)]))
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: capture the RAW body — re-serializing req.body changes the bytes and breaks the HMAC.
app.post("/hooks/bron", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyBronWebhook(req.body, req.get("X-BRON-Signature-V2"), process.env.BRON_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// ... dedupe on X-BRON-Delivery-Id, enqueue, return quickly; do the pull work asynchronously
res.status(204).end();
});
import hmac, hashlib, time
TOLERANCE_SECONDS = 300
def verify_bron_webhook(raw_body: bytes, signature_v2_header: str, secret: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in (signature_v2_header or "").split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
return False # stale or replayed
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Keep your clock NTP-synced; the window is symmetric, so a receiver clock far ahead refuses fresh deliveries too. Reject anything that fails verification. Handlers should be idempotent — a delivery can repeat.
4. MCP server
An MCP server is mounted at /mcp (streamable HTTP, JSON-RPC
2.0), so an AI agent can query matters directly. It is a thin wrapper over the same functions the
REST routes call, with the same scope checks, the same ethical-wall subtraction, and the same
owner-scoping — it is not a side door. The REST surface in §2 runs these same tool handlers, which
is why the two cannot answer differently.
Authorization is never cached. Every POST, GET and DELETE re-presents its key and is
re-authorized from scratch, so a revoked key stops working on its next call. A session holds transport
and capability state, never an authorization lease.
Connecting
{
"mcpServers": {
"bron-law": {
"type": "http",
"url": "https://<your-host>/mcp",
"headers": { "Authorization": "Bearer brk_..." }
}
}
}
An unauthenticated request answers 401 with a WWW-Authenticate header naming
/.well-known/oauth-protected-resource, so a client that has no key can discover the authorization
server and run the code flow instead. A client that already holds a key never reaches that path.
Protocol revisions
Four are accepted: 2025-11-25 (the default when a client offers nothing we know),
2025-06-18, 2025-03-26 and 2024-11-05. A request arriving with no protocol header at all is
read as 2025-03-26. The authoritative list is SUPPORTED_PROTOCOL_VERSIONS and
DEFAULT_PROTOCOL_VERSION in backend/src/mcp/index.ts.
Two things are gated on the revision. Tasks — the tasks/* methods and task-augmented
tools/call — are advertised and answered only on 2025-11-25; on an earlier revision they are
-32601. And structuredContent on a tool result appears only from 2025-06-18 upward; below
that, the same JSON arrives in the text content block alone.
Methods
Fourteen request methods, plus two spellings of the post-initialize notification:
| Method | Notes |
|---|---|
initialize | Negotiates the revision, records the capabilities the client declared, and mints an Mcp-Session-Id response header. Return that header on later requests |
notifications/initialized, initialized | The handshake notification, under either name. Acknowledged by staying silent |
ping | {} |
tools/list | Paginated; see below |
tools/call | Optionally task-augmented on 2025-11-25 |
prompts/list, prompts/get | The published prompt allow-list, and one prompt resolved for this firm |
resources/list, resources/read | Existing tools addressed by URI |
completion/complete | Argument completion. Today it answers case_id and matter_id, resolved through the same matter funnel every tool uses, and returns nothing for any other argument |
logging/setLevel | Per-session minimum severity for notifications/message. It needs a session: without one it answers an error rather than pretending the threshold was stored |
tasks/get, tasks/result, tasks/list, tasks/cancel | 2025-11-25 only |
Anything else is -32601. Note that resources/templates/list is not implemented — it is named
in the server's sessionless-method allow-list and in the pagination type, but there is no handler, so
it falls through to method-not-found. Do not build against it.
Client answers to sampling/createMessage and elicitation/create are JSON-RPC responses — an
id plus result or error, no method — posted as ordinary requests. They resolve the promise
parked under that id in that session, and no session can consume another's parked request.
GET /mcp is a stream, not a 405
Earlier revisions of this document said GET /mcp returns 405 and that there is no SSE stream.
That is wrong. GET /mcp, carrying a session id from initialize, opens a held
text/event-stream channel for server-initiated requests: sampling/createMessage, and URL-mode
elicitation/create for clients that negotiate it. A heartbeat keeps an idle proxy from killing it
silently.
- Without a recognised session for the presenting principal:
404. - With a stream already open on that session:
409. - The held stream re-authorizes every 30 seconds by re-running the real key resolver. A revoked key, or a resolver failure, drops the stream — a long-lived connection is not allowed to become a cached auth lease.
DELETE /mcp closes the stream, removes the session, and rejects every unanswered server request.
Tools
Call tools/list. Do not work from a table. It is the exact wire projection of the runtime
registry, pinned by toolMount.invariants.test.ts, and it returns each tool's title, description,
annotations, input schema and output schema. A table of seven tool names lived in this section and
was still there when the surface had grown past a hundred, which is the reason this paragraph replaced
it.
As of this writing the registry holds 121 tools, and that number is a snapshot of the day it was
measured, not a contract — tools/list is the current answer. Four of them write (below); the rest
read. Each tool's required scope is enforced server-side per call and is deliberately not on the
wire.
tools/list pages at 50, and a client that ignores nextCursor sees a fraction of the surface.
The page size is MCP_LIST_PAGE_SIZE in backend/src/mcp/pagination.ts. A result carrying
nextCursor has more tools; pass it back as params.cursor until it stops appearing. The same
applies to prompts/list and resources/list. Cursors are signed and bound to the calling principal
and to the exact set they were minted over, so one cannot be carried across keys or used after the
key's reach narrows — revoke a matter between pages and the outstanding cursor stops verifying rather
than serving what page one was still allowed to see. (On a deployment with no signing secret
configured the server mints no cursor and serves the whole funnelled list; an offered cursor there is
unverifiable and refused.)
The read surface covers, in the registry's own module groupings: the matter record and chronology; the docket and the triaged agenda; the knowledge graph and hybrid retrieval; documents and their citation reports; discovery, its productions and privilege log, and the discovery record — the validation runs, service receipts, redaction receipts and inbound-import receipts; collections and custody chains; depositions, witnesses, experts and trial preparation; the fact spine; strategy, claim analysis and valuation; money (invoices, time, trust, fee arrangements); intake and inbound triage; signing; the firm desk and its approvals; the client-portal experience (requests, decisions, updates, messages, shared documents, portal access); second-chair reads; firm control (roster, ethical walls, conflicts, prompt registry, supervision ladder, credential inventory, usage, webhook subscriptions); and the record ledgers (chain verdict, attestation receipts, disclosures, audit entries, export previews).
Naming a few so the shape is concrete: list_matters, retrieve, get_knowledge_graph,
get_fact_record, get_privilege_log, get_trust_balance, get_ledger_chain_verdict,
get_ethical_walls. tools/list has the rest.
Each result is a single text content block holding the same stamped JSON as the REST twin, plus
structuredContent on 2025-06-18 and above. A tool-level failure — bad arguments, a case id this
key cannot reach, a missing scope — comes back as isError: true with a readable sentence rather
than a JSON-RPC error, so an agent can recover from it.
get_knowledge_graph truncates large graphs to fit a model's context (400 entities / 800
relationships) and says so explicitly via truncated: true and a truncation_note pointing at the
REST export. It never silently returns a partial graph.
The four writes
Every other tool is a read. These four are not, and none of them can cause an effect outside the firm — nothing here sends, files, signs, serves, calendars or moves money.
| Tool | Scope | What it writes | What it cannot do |
|---|---|---|---|
propose_action | write | One suggest-capped row in the firm's approvals inbox, then reads it back so the receipt is not fabricated | Execute. The executor is not reachable from this path, no confirmation nonce is minted or returned, override flags are stripped and surfaced to the attorney, and a matter holds at most 25 undecided proposals |
add_case_note | write | One internal note on one accessible matter, privileged from the moment it is written. idempotency_key is required | Reach a client-safe key (refused), or have any external effect. Reusing a key with different content is refused |
begin_upload_session | write | One checksum-bound resumable upload session for a PDF. Spends no grant | Accept a firm id, bucket, object path, classification or visibility value from the caller |
complete_upload | write | Verifies the staged bytes against the declared size and SHA-256, atomically spends the grant, then runs BRON's canonical pending-review intake | Classify the document. It lands unclassified and unreadable through MCP document reads until a licensed attorney reviews privilege |
The upload pair additionally requires a firm-admin-minted, expiring, single-use external-agent
grant bound to this API key and carrying the upload capability — see External-agent grants in
§2. A missing or checksum-mismatched staging object leaves the grant unspent, and the whole lane is
refused while no malware scanner is registered on the deployment.
propose_action is the one that matters for anything consequential. It returns
{status: "pending_confirmation", approval_id, what_will_happen, executed: false, capped_at_rung: "suggest"}. A person at the firm approves it inside BRON LAW, where the full gate
stack re-runs from the immutable stored row. This API is never issued a confirmation token and cannot
approve its own proposal.
Reads say how far they were checked
Every read answer carries {provenance_ids, verification_status, as_of}, and every fact, passage and
deadline inside it carries its own. grounded means the item resolves to rows in the firm's record —
a custody claim, not a verdict on the item's truth. ungrounded means it cites nothing. unknown
means verification could not be determined, and such an item must not be presented as established.
as_of is the freshness of the data, never of the response.
Collection-bearing reads additionally carry a read_state of read, empty or unreadable,
and when it is unreadable the collection is null and its count is null rather than [] and
0. Not every tool carries it: at time of writing 58 of the 121 declare it in their output schema,
and each tool's own outputSchema from tools/list is the answer for that tool. The ones without it
raise on a read fault instead, as a tool error. Either way, an unread collection does not arrive
as an empty one — which is the property that matters, because an agent told facts: [] by a read
that never happened has been told the matter is empty.
docs/MCP_VERIFIED_SUBSTRATE.md is the full contract for all of this.
Rate limits and the sensitivity class
Both apply here identically, with one difference worth planning around: MCP has its own budget,
separate from REST and larger, because an MCP caller is the firm's own AI reading the firm's own
record. See §1. retrieve and the drafting:check tools draw on the expensive lane; everything else
on the read lane. Over-limit calls come back as a tool error carrying the retry delay. A
client_safe-class key gets the privilege-gated graph no matter what the model passes for
client_safe, and the work-product tools refuse it outright.
POST /mcp is exempt from the server-wide per-IP backstop; the per-key MCP budget is the control on
this surface.
Raw handshake
curl -s -D- -X POST https://<host>/mcp \
-H "Authorization: Bearer brk_..." -H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize",
"params":{"protocolVersion":"2025-11-25","capabilities":{}}}'
# -> Mcp-Session-Id: <id>
curl -s -X POST https://<host>/mcp \
-H "Authorization: Bearer brk_..." -H "Mcp-Session-Id: <id>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# -> {"tools":[...50 of them...],"nextCursor":"..."} <- follow it
curl -s -X POST https://<host>/mcp \
-H "Authorization: Bearer brk_..." -H "Mcp-Session-Id: <id>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"cursor":"..."}}'
curl -s -X POST https://<host>/mcp \
-H "Authorization: Bearer brk_..." -H "Mcp-Session-Id: <id>" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call",
"params":{"name":"retrieve","arguments":{"case_id":"…","query":"March invoice dispute","k":5}}}'
tools/list, prompts/list, prompts/get, resources/list, resources/read,
completion/complete and ping are sessionless discovery reads and do not need the session header.
Everything else does.
5. Reconstructing the graph in your app
To hold a live mirror of a matter's graph in your own system:
1. Pin the schema version. Every payload stamps schema_version (currently 1.0). Treat an
unrecognized version as a signal to re-check your mapping rather than to parse optimistically.
2. Backfill with an export. Pull GET /matters/:id/knowledge-graph?format=jsonld (or graphml).
Identifiers are stable and derived from the underlying row ids — urn:bron:kg:entity:<id> and
urn:bron:kg:rel:<id> — so re-pulls reconcile against what you already stored instead of duplicating
it. Use these as your primary keys; do not key on entity names, which are merged and normalized
over a matter's life.
3. Resolve provenance on demand. Entities and chunks cite source_ids rather than embedding
source content. Dereference through GET /documents/:id for metadata plus a 300-second signed URL.
Fetch immediately and store your own copy if you need one — the URL is a short-lived credential, not
a permalink. Some sources will legitimately 404: privileged and work-product material is withheld from
API keys by design, so treat a 404 as an expected outcome, not an error to retry.
4. Sync incrementally with webhooks. Subscribe to document.ingested and kg.updated:
| Event | Do this |
|---|---|
document.ingested | Re-pull GET /documents/:id for the new document_id and reconcile that source |
kg.updated | Re-pull the case graph export and reconcile against your stored urns |
kg.updated fires only when a rebuild actually stored a new graph. A partial rebuild that
preserved the prior graph emits nothing, so you are never sent on a no-op pull.
5. Reconcile, don't append. A rebuild can merge two entities that you hold separately (the same person named three ways in three documents collapses into one node) or split them apart. Diff the pulled urn set against yours and delete what is gone — appending only will drift.
6. Keep the gates on your side. What you receive has already passed the wall and privilege gates for that key's owner. If you re-expose it to your own users, apply your own authorization — inheriting one owner's view into a multi-user app would widen access.
Sketch
const HEADERS = { Authorization: `Bearer ${process.env.BRON_API_KEY}` };
async function syncMatterGraph(caseId) {
const res = await fetch(
`${BASE}/matters/${caseId}/knowledge-graph?format=jsonld`, { headers: HEADERS });
if (!res.ok) throw new Error(`graph pull failed: ${res.status}`);
const doc = await res.json();
if (doc.schema_version !== "1.0") {
console.warn(`unexpected schema_version ${doc.schema_version} — verify the mapping`);
}
const nodes = doc["@graph"].filter((n) => n["@type"] !== "Relationship");
const edges = doc["@graph"].filter((n) => n["@type"] === "Relationship");
await upsertNodes(caseId, nodes.map((n) => ({ urn: n["@id"], type: n["@type"], name: n.name })));
await upsertEdges(caseId, edges.map((e) => ({
urn: e["@id"], from: e.source, to: e.target, kind: e.relationship,
})));
// Reconcile: anything we hold for this matter that the pull no longer contains is gone.
await deleteMissing(caseId, [...nodes, ...edges].map((n) => n["@id"]));
}
6. Versioning
Every stamped payload carries schema_version, currently 1.0. Here is exactly what that
promises.
Within a version we may add, never remove or repurpose. New fields can appear on any payload at
any time — client_safe_forced_by_key arrived this way. Parse permissively: ignore fields you do not
recognize rather than rejecting the response. A field documented here will not disappear, change
type, or change meaning while schema_version reads 1.0.
A breaking change bumps the number. Removing a field, renaming one, changing its type, or
changing what it means is a new schema_version. Both versions run side by side for a deprecation
window announced before the change, so you can pin, test, and migrate rather than being cut over.
What the version does not cover. It describes the payload shape, not the data. The graph
itself is rebuilt as a matter develops: entities merge, split and change confidence, and the same
export pulled twice can legitimately differ. Version your mapping against schema_version; reconcile
your content against the stable urn: identifiers (see §5).
Check it, and act on a surprise. Treat an unrecognized schema_version as a signal to re-check
your mapping — log it, alert, and keep parsing only what you already understand. It is not an error
to retry.
Two other things are stable and safe to depend on: the urn:bron:kg:entity:<id> /
urn:bron:kg:rel:<id> identifier formats, and the error contract ({"detail": "..."} plus the
status codes in §1). Scopes and sensitivity classes are additive — new ones may appear; existing ones
keep their meaning.