Blog / How-to guides

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.

Acme and Globex are each party to the services agreement, which contains the termination clause, which imposes the data return owed by Globex; the clause text is a passage that mentions them.AcmeGlobexClause text (passage)Services AgreementTermination clauseData returnGlobex, who owes itPARTY_TOPARTY_TOCONTAINSmentionsIMPOSESOWED_BY

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_TO an agreement, an agreement CONTAINS a clause, a clause IMPOSES an obligation, an obligation is OWED_BY a 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:

bash
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:

The same question asked in this matter's scope returns the termination clause with its source and date; asked in another matter's scope on the same node it finds nothing and says so.Your nodeWhat does Globex oweon termination?Scope: this matterThe clause text, itssource and dateScope: another matterNothing stored, and itsays so
bash
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:

bash
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.

bash
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:

bash
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:

python
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:

bash
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_extraction says 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.
  • 422 missing '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

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.

Related