A matter memory for a legal team: parties and clauses as a graph
11 October 2026 · Semurg · 7 min read · Markdown
Verified: every command below ran on a fresh node of r11.78.20261012.bc0d94f (how)
build-on-semurglegalknowledge-graphagentsblueprint
A legal team does not need another place to put documents. It needs to ask questions that follow the connections between them: which obligations fall on our client if this clause is triggered, what did we agree with this counterparty about termination, which contracts name this person as a signatory. Those are walks over relationships, not keyword searches, and the relationships are known when a contract is reviewed. This blueprint keeps them.
Everything below uses only what the published release does.
The shape of the data
- One scope per matter. A scope is a separate memory. An agent working on one matter connects with that matter's scope and sees nothing of any other; a firm-wide scope can hold what every matter shares.
- Entities are the things with names: the parties, the agreement, each clause, each obligation, each person who signs.
- Relationships are typed edges between them: a party is
PARTY_TOan agreement, an agreementCONTAINSa clause, a clauseIMPOSESan obligation, an obligation isOWED_BYa party. - Passages are the clause text itself, each one naming the entities it is about. When a question reaches one of those entities, the passage comes back whole, so the lawyer reads the clause rather than a summary of it.
- Dates and sources ride on every write: the agreement's effective date and the document it came from come back with every fact they sourced.
A counterparty that appears in fifty agreements is one entity with fifty edges, not fifty copies. Rename it, forget it or add a fact about it once, and every agreement sees the change.
Step one: a node and a matter
Install a node (Ubuntu, as root; the install page has the details), keep the API token the installer prints, and pick a scope name for the matter. Every call below sends it.
Step two: write one agreement into the matter
Your review process, or the agent that reads the contract, decides the entities and relationships. The node stores them as they are sent, together with the clause text, the effective date and the document's name:
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/memory/remember \
-H "Authorization: Bearer $SEMURG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope":"matter-acme-globex","date":"2026-03-01","source":"Acme Globex services agreement, signed copy",
"facts":{"entities":[{"name":"Acme","type":"Company"},{"name":"Globex","type":"Company"},
{"name":"Services Agreement","type":"Agreement"},
{"name":"Termination clause","type":"Clause"},
{"name":"Data return","type":"Obligation"}],
"relations":[{"source":"Acme","target":"Services Agreement","type":"PARTY_TO"},
{"source":"Globex","target":"Services Agreement","type":"PARTY_TO"},
{"source":"Services Agreement","target":"Termination clause","type":"CONTAINS"},
{"source":"Termination clause","target":"Data return","type":"IMPOSES"},
{"source":"Data return","target":"Globex","type":"OWED_BY"}]},
"passages":[{"text":"Termination clause. Either party may end the Services Agreement on thirty days written notice. On termination Globex must complete the Data return: every record it holds for Acme, within fourteen days.",
"mentions":["Termination clause","Services Agreement","Globex",
"Data return","Acme"],
"key":"services-agreement-termination"}]}'Expected: a JSON answer with "ok": true
A relationship may name an entity an earlier call stored. If it names one that is neither in this call nor stored, the call is refused by name and nothing is stored, so a misspelt party or clause never slips into the matter.
Step three: ask the matter
What do we have on Globex? Recall starts at the entity and returns every fact connected to it, in both directions, each with the source and date it came from:
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/memory/recall \
-H "Authorization: Bearer $SEMURG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope":"matter-acme-globex","subject":"Globex"}'Expected: "state": "answered", with Globex as a party to the Services Agreement and the Data return obligation it owes
A question rather than a name: context finds every entity the question names, walks out from them, and returns the ranked facts with the passages that mention them, so the clause text comes back with the answer:
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/memory/context \
-H "Authorization: Bearer $SEMURG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope":"matter-acme-globex","query":"What must Globex do on termination?","hops":2}'Expected: "state": "answered", with the termination clause's text, including the fourteen-day data return
And the wall between matters holds: the same question in a different matter's scope finds nothing, and says so.
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/memory/recall \
-H "Authorization: Bearer $SEMURG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope":"matter-another-client","subject":"Globex"}'Expected: "state": "nothing_stored": the other matter holds nothing about Globex
Step four: a cross-reference added later
A later review finds that the termination clause points to a schedule. One relationship, added on its own:
export SEMURG_API_TOKEN=<your token>
curl -sS http://localhost:4000/v1/memory/relate \
-H "Authorization: Bearer $SEMURG_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"scope":"matter-acme-globex","from":"Termination clause","to":"Data handling schedule","type":"REFERS_TO"}'Expected: a JSON answer with "ok": true; the schedule is created as a new entity
The matter assistant, in Python
The same calls from the Python client, which ships in the client archive at https://one.semurg.io/dl/clients.tar.gz (unpack it, then pip install -e clients/python). This program answers a question from a matter and prints each fact and passage with the id of what sourced it and its date, so whoever drafts the reply can quote the clause:
import os
import sys
from semurg import Client
matter = sys.argv[1] # for example matter-acme-globex
question = sys.argv[2] # for example "What must Globex do on termination?"
with Client("http://localhost:4000", token=os.environ["SEMURG_API_TOKEN"]) as memory:
answer = memory.context(matter, question, hops=2)
if answer.get("state") != "answered":
print("Nothing in this matter answers that:", answer.get("state"))
sys.exit(1)
for item in answer.get("items", []):
print("-", item.get("kind"), item.get("text"),
"| source id:", item.get("source_id"),
"| date:", item.get("source_date"))Read state before the items: an empty answer says it is empty rather than inventing one.
An agent that drafts with the matter's memory
Point the drafting agent at the same node with the matter's scope in the X-Semurg-Scope header, and it can call memory_context itself while it writes. For Claude Code:
export SEMURG_API_TOKEN=<your token>
claude mcp add-json --scope project semurg '{
"type": "http",
"url": "http://127.0.0.1:4000/v1/mcp",
"headers": {
"Authorization": "Bearer ${SEMURG_API_TOKEN}",
"X-Semurg-Scope": "matter-acme-globex"
}
}'A project-scope entry keeps the matter's scope with the matter's working folder; Claude Code asks once to approve it. Other agents and frameworks connect the same way; the agents page has each configuration.
Scale and cost
A single node is free, with no licence key, and it is the full engine. A cluster is licensed per node; talk to us about the one you need. We have not measured this workload, so this post states no speed for it. The engine's figures for each data domain are being re-measured on new hardware and land on the benchmark page domain by domain.
What it does not do today
- It does not read contracts for you. Your review step or your agent decides the entities, relationships and clause text; the node stores what it is sent. Where an extraction model is installed and serving, the node also reads stored text afterwards (the reply's
model_extractionsays which); how well and how quickly it reads contracts has not been measured from outside yet. - It matches meaning only with the node's embedding model installed: until then, a question about "cancellation" does not find a clause that only says "termination", so use the words your entities and types use. A node that agrees to downloads installs the model once the signed catalogue lists it.
- Forgetting an entity deletes it, its facts and the passages linked to it; a passage that contains the name without being linked to it (for example text stored before the entity was) stays, and the reply lists it in
passages_retained, so forget that document too if it must go. What is deleted is unreachable at once, and its bytes are wiped from the disk later, with space reclaim, which is not released yet. - It does not detect, redact or classify personal or privileged information, and it is not legal advice. What goes into a matter's memory, and who may connect to it, is the firm's decision.
When something is refused
The node refuses by name. The ones this app meets:
401: the token is missing or wrong.422missing 'scope': every HTTP memory call needs a scope in its body.- A passage whose mention does not appear in its text is refused, naming the mention.
- More than 1000 entities in one call, or a name longer than 1024 bytes, is refused with
422: split the write into several calls. - Forgetting a name that does not exist exactly answers
404, with up to three close names to check. "state": "nothing_stored"is an answer, not an error: the name matched nothing in this scope.- A stored name that shares one ordinary word with a longer question can come back as if it answered it. Check that what comes back covers the question before relying on it.
Next
- Give your agent persistent memory with MCP in five minutes: the node and the seven memory tools.
- A support agent that remembers every customer: the same pattern for a support desk.
How these commands were checked
Our test farm ran each of the 5 command blocks marked runnable, exactly as written, with the answer checked against the expectation printed under it, against a fresh node of the published release r11.78.20261012.bc0d94f. A post is published only when every one of them passed there.