Skip to content

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 (POST for JSON-RPC, GET for the server-initiated event stream, DELETE to 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:

ClassWhat it seesWho can mint it
attorney (default)Everything described above, including the full attorney-grade knowledge graph with privileged provenanceA firm administrator
client_safeThe 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 offAny 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_with list, 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:

HeaderSays
RateLimit-Limit / -Remaining / -ResetThe (surface, lane) this request spent from. -Reset is seconds until the lane refills to its full limit, not the wait for one token
RateLimit-PolicyBoth 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-Enforcementper-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-Enforcement says 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:

BackstopApplies toDefault
Server-wideEvery request except /health and /mcp, including the unauthenticated GET /openapi.json1000 requests / 15 min
DraftingPOST /drafting/verify-citations, /drafting/table-of-authorities, /drafting/pre-file-check — the three that spend on a model. /drafting/link-authorities is deterministic and exempt40 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

ScopeGrants
readMatter records, deadlines, and every granular read scope below
writeEvery scope in this table — but never a non-inheritable scope (below)
kg:readKnowledge graph + firm graph, including the JSON-LD / GraphML exports
retrieval:queryHybrid RAG retrieval
docs:readProvenance dereference (GET /documents/:id)
drafting:checkThe four stateless drafting checks (/drafting/*) the editor add-ins use
tasks:readThe proactive tranche: the triaged agenda, the Matter Mind, the daily brief
analysis:readThe 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

StatusMeaning
400Malformed request (missing content, query over 2000 chars, unsupported format)
401Missing, invalid, or revoked key
403The key lacks the scope this endpoint requires
404Not found or not reachable by this key — deliberately indistinguishable
409An Idempotency-Key already names a row with different content. The original stands; nothing was written
413Document text over 500,000 characters (drafting checks)
429Rate limited — either this key's per-minute lane budget or a per-IP backstop; honour Retry-After
502An upstream check (citation verification, ToA, pre-file) could not complete — not a pass
503A 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_more says whether another page exists.
  • source_truncated says whether the read behind the collection stopped at its own row cap before pagination began. has_more: false with source_truncated: true means there are no more pages and there are more rows: total is the size of what was read, not of the record, and paging cannot reach past a capped read. Narrow the query instead. The attestation's verification.notes names 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 paramValuesEffect
client_safe1Narrow to the producible client view — apply the client-portal privilege gate (fail-closed). Omitted / not 1 returns the full attorney view (see below).
formatjson (default), jsonld, graphmlExport 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:

HeaderMeaning
X-Bron-Payload-IdThe receipt id for this release
X-Bron-Pinned-VersionThe document_versions row whose bytes the URL points at
X-Bron-Render-Sha256sha256 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.

EndpointBodyReturns
POST /drafting/verify-citationstext, optional case_idThe citation-gate report: every citation extracted, its verification status, holding check, and whether the document is blocking
POST /drafting/table-of-authoritiestext{toa, rendered} — the grouped Table of Authorities plus a rendered text block
POST /drafting/link-authoritiestext{links, total} — each citation's character span and a resolved external URL (deterministic; no model spend)
POST /drafting/pre-file-checktext, optional case_id, optional doc_typeThe 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):

RouteWho
POST /agent-grantsA firm administrator of the matter's own firm who also personally reaches that matter through ensureCaseAccess. Returns the plaintext token once
GET /agent-grantsThe firm's grants, firm administrator
POST /agent-grants/:id/revokeFirm 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:

  1. The presenting key's own reach, through the same owner-scope funnel every other endpoint uses.
  2. 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.
  3. 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.
  4. The key's own scope. read needs read; propose and upload need write. The grant says what may be asked for; the credential still says what it may do.
  5. The credential class. A client_safe key 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).

EventFires when
matter.resolvedA matter is marked resolved (settlement, verdict, dismissal)
hearing.debriefedA hearing debrief is recorded
document.ingestedA document finishes ingest and is retrievable
kg.updatedA knowledge-graph rebuild stored a new graph
record.element_turnedAn 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_signedA 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_resolvedAn 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_mergedEvery element of a record pull request landed and the PR is marked merged (ids and counts only)
webhook.testYou 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)>
  1. Parse t and v1 out of the header (take t from here, not from X-BRON-Timestamp).
  2. Reject if |now - t| > 300 seconds. 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.
  3. 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:

MethodNotes
initializeNegotiates the revision, records the capabilities the client declared, and mints an Mcp-Session-Id response header. Return that header on later requests
notifications/initialized, initializedThe handshake notification, under either name. Acknowledged by staying silent
ping{}
tools/listPaginated; see below
tools/callOptionally task-augmented on 2025-11-25
prompts/list, prompts/getThe published prompt allow-list, and one prompt resolved for this firm
resources/list, resources/readExisting tools addressed by URI
completion/completeArgument 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/setLevelPer-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/cancel2025-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.

ToolScopeWhat it writesWhat it cannot do
propose_actionwriteOne suggest-capped row in the firm's approvals inbox, then reads it back so the receipt is not fabricatedExecute. 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_notewriteOne internal note on one accessible matter, privileged from the moment it is written. idempotency_key is requiredReach a client-safe key (refused), or have any external effect. Reusing a key with different content is refused
begin_upload_sessionwriteOne checksum-bound resumable upload session for a PDF. Spends no grantAccept a firm id, bucket, object path, classification or visibility value from the caller
complete_uploadwriteVerifies the staged bytes against the declared size and SHA-256, atomically spends the grant, then runs BRON's canonical pending-review intakeClassify 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:

EventDo this
document.ingestedRe-pull GET /documents/:id for the new document_id and reconcile that source
kg.updatedRe-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.