---
title: "A matter memory for a legal team: parties and clauses as a graph"
slug: build-legal-matter-memory
date: 2026-10-11
author: Semurg
section: how-to
tags: [build-on-semurg, legal, knowledge-graph, agents, blueprint]
summary: A blueprint for a contracts team: each matter its own memory, parties and clauses as a graph, clause text returned whole, and a drafting agent that can ask the matter what it already knows.
status: published
covers: [route:POST /v1/memory/remember, route:POST /v1/memory/recall, route:POST /v1/memory/context, route:POST /v1/memory/relate, sdk:context, mcp:memory_context]
constants: [1000, 1024]
---
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.

> Diagram matter-graph: 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.

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](/install) 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 runnable kind=shell-node needs=token
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"}]}'
```

```expect
expect: a JSON answer with "ok": true
assert: json:.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:

> Diagram matter-wall: 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.

```bash runnable kind=shell-node needs=token
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"}'
```

```expect
expect: "state": "answered", with Globex as a party to the Services Agreement and the Data return obligation it owes
assert: json:.ok==true and .state=="answered" and ([..|strings|ascii_downcase|select(contains("services agreement"))]|length>0)
```

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 runnable kind=shell-node needs=token
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}'
```

```expect
expect: "state": "answered", with the termination clause's text, including the fourteen-day data return
assert: json:.state=="answered" and ([..|strings|ascii_downcase|select(contains("within fourteen days"))]|length>0)
```

And the wall between matters holds: the same question in a different matter's scope finds nothing, and says so.

```bash runnable kind=shell-node needs=token
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"}'
```

```expect
expect: "state": "nothing_stored": the other matter holds nothing about Globex
assert: json:.state=="nothing_stored"
```

## 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 runnable kind=shell-node needs=token
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"}'
```

```expect
expect: a JSON answer with "ok": true; the schedule is created as a new entity
assert: json:.ok==true
```

## 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](/agents) 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](/benchmark) 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

- [Give your agent persistent memory with MCP in five minutes](/blog/agent-memory-mcp-five-minutes): the node and the seven memory tools.
- [A support agent that remembers every customer](/blog/build-customer-support-memory): the same pattern for a support desk.
