How to Reconcile Contract Amendments With the Original Agreement
Extract the original agreement and each amendment with Unsiloed, then use Python to apply the changes in effective-date order. The result is the current wording of every clause, with the document and page that set it.


An amendment only records what changed. To know what a contract says today, you need the original agreement plus every amendment, applied in the right order. In this guide, we use Unsiloed to extract the original terms and each amendment's changes, then use Python to apply them by effective date. The result is the current wording of each term we track, with the document and page that set it.

Why the Latest Document Is Not Enough
Our example starts with a hosted software services agreement between Northstar Systems LLC and Harbor Analytics Inc. Three amendments change different parts of it.
| Document | Signed | Effective | Changes |
|---|---|---|---|
| Original | January 10, 2026 | January 15, 2026 | Establishes the starting terms |
| Amendment 1 | March 15, 2026 | April 1, 2026 | Raises the monthly fee and extends the renewal notice period |
| Amendment 2 | June 15, 2026 | June 1, 2026 | Raises the liability cap and adds a monthly report |
| Amendment 3 | September 10, 2026 | October 1, 2026 | Raises the fee again and removes the monthly meeting |
Amendments rarely restate the contract. They point at numbered clauses, called sections, and say what happens to them. In Amendment 1, the fee change reads as follows.
Section 4.1 of the Agreement is deleted and replaced with the following: "The Customer shall pay the Provider a service fee of USD 9,500 per month."
"Deleted and replaced" means remove the old wording of that section and put the supplied wording in its place. Amendment 3 later targets the same section, including its earlier change.
Section 4.1 of the Agreement, as replaced by Amendment 1, is deleted and replaced with the following: "The Customer shall pay the Provider a service fee of USD 10,250 per month."
An effective date is the date a change starts to apply. It can differ from the signing date. Amendment 2 is signed on June 15 but takes effect on June 1. Store both dates, and order these changes by their effective dates.
Reading only Amendment 3 would miss the 60-day notice period from Amendment 1 and the liability cap from Amendment 2. It would also miss the original renewal and governing law terms, which none of the amendments changes. Keep those links to earlier clauses intact, and preserve section references when splitting a document into smaller pieces for retrieval.
Applying all three amendments gives us the following changes as of October 26, 2026. This is the combined view we'll build, with a source document and page for each term.
| Term | Original | October 26, 2026 |
|---|---|---|
| Monthly fee | USD 8,000 | USD 10,250 |
| Notice to prevent renewal | 30 days | 60 days |
| Liability cap | USD 96,000 | USD 150,000 |
| Monthly service meeting | Required | Deleted |
| Monthly service report | No Section 5.2 | Due within five business days after month end |
Extract the Original Agreement's Key Terms
Extract a starting set of clauses before processing changes. A schema defines the fields and types we want back. Keep the section number with the complete text of each selected clause, including conditions and units.
Download the original agreement, Amendment 1, Amendment 2, and Amendment 3. Save them in one folder. The snippets use Python with requests installed and an Unsiloed API key in the UNSILOED_API_KEY environment variable. To skip extraction, download the saved results for the original, Amendment 1, Amendment 2, and Amendment 3 into the same folder and go to Put the Changes in Effective-Date Order.
Create extract.py in that folder with the schema below. We select seven clauses covering the terms we will track. The agreement identifier ties the documents together, while the dates distinguish signing from taking effect.
def string(description):
return {"type": "string", "description": description}
def obj(properties):
return {"type": "object", "properties": properties, "required": list(properties)}
identity = {
"agreement_id": string("Identifier of the original agreement"),
"signed_date": string("Date signed, YYYY-MM-DD"),
}
term = obj({
"section": string("Section number, such as 4.1"),
"text": string("Complete clause text, verbatim, without its section heading"),
})
original_schema = obj({
**identity,
"effective_date": string("Agreement effective date, YYYY-MM-DD"),
"terms": {"type": "array", "items": term,
"description": "Sections 2.1, 2.2, 2.3, 4.1, 5.1, 7.1, 9.1 in document order"},
})
Append this request helper to extract.py. The extraction endpoint returns a job ID, and we check the job every few seconds until the result is ready.
import json, os, time
import requests
BASE = "https://prod.visionapi.unsiloed.ai"
HEADERS = {"api-key": os.environ["UNSILOED_API_KEY"]}
def extract(path, schema):
with open(path, "rb") as f:
job = requests.post(f"{BASE}/v2/extract", headers=HEADERS,
files={"pdf_file": (path, f, "application/pdf")},
data={"schema_data": json.dumps(schema), "model": "gamma",
"enable_citations": "true"})
job.raise_for_status()
while True:
result = requests.get(f"{BASE}/extract/{job.json()['job_id']}", headers=HEADERS)
result.raise_for_status()
result = result.json()
if result["status"] == "failed":
raise SystemExit(f"Job failed: {result.get('error')}")
if result["status"] == "completed":
return result["result"]
time.sleep(3)
original = extract("original.pdf", original_schema)
with open("original.json", "w") as f:
json.dump(original, f, indent=2)
The gamma setting selects the extraction model, and enable_citations adds the page location of each value. Every extracted field comes back with a value, a score, and a citation. The grounding_score says how confident the model is that it found the value in the document, and the extraction_score says how confident it is in the value itself. Both run from zero to one. The citation gives the page and a bounding box, the rectangle on the page where the value appears.
This is the fee clause's text field from original.json.
{
"score": {
"grounding_score": 0.998,
"extraction_score": 0.998
},
"value": "The Customer shall pay the Provider a service fee of USD 8,000 per month.",
"citation": {
"bbox": [52, 351, 392, 366],
"page": 1,
"page_width": 612.0,
"page_height": 792.0
}
}
Extract Each Amendment as a List of Changes
An amendment needs a different schema because it describes edits to existing terms. We extract one record per change, with the target section, the operation, the new text, and the effective date. The date sits on each change because one amendment can contain changes that start at different times.
The recitals, the opening statements that explain the background to an agreement, identify the original contract but don't change anything. Neither does the closing line confirming that all other terms remain unchanged. The schema description tells the model to leave both out.

Append this schema and loop to extract.py.
change = obj({
"section": string("Target section in the original agreement, not amendment numbering"),
"operation": {"type": "string", "enum": ["replace", "add", "delete"]},
"text": string("Complete new clause verbatim, without enclosing quotation marks. Empty string for delete."),
"effective_date": string("Date this change takes effect, YYYY-MM-DD, not signing date"),
})
amendment_schema = obj({
**identity,
"changes": {"type": "array", "items": change,
"description": "Every operative change, in document order. Exclude recitals and unchanged-terms confirmations."},
})
for n in (1, 2, 3):
amendment = extract(f"amendment-{n}.pdf", amendment_schema)
with open(f"amendment-{n}.json", "w") as f:
json.dump(amendment, f, indent=2)
Run it from the folder containing the PDFs.
python3 extract.py
In each amendment file, the change records are in ["changes"]["value"], and each field inside a record has its own value, score, and citation, like the original terms. For a deletion, the text is an empty string and the delete operation carries the instruction, so we take page numbers from the operation's citation.
Put the Changes in Effective-Date Order
With every change extracted, we use Python to replay them on top of the original terms. Each section keeps a history of the documents that changed it, so the current wording always points back to its source.
Create reconcile.py and start with the original terms.
import json
def load(path):
with open(path) as f:
return json.load(f)
def page(field):
return field["citation"]["page"]
original = load("original.json")
terms = {}
for term in original["terms"]["value"]:
terms[term["section"]["value"]] = {
"text": term["text"]["value"],
"history": [{"document": "original.pdf", "operation": "original",
"effective_date": original["effective_date"]["value"],
"page": page(term["text"])}],
}
Append the code that collects every change and sorts it by effective date. The dates are ISO strings (YYYY-MM-DD), so sorting them as text puts them in date order. Each amendment must carry the original's agreement identifier, which stops a change from another contract being applied to this one.
changes = []
for n in (1, 2, 3):
amendment = load(f"amendment-{n}.json")
if amendment["agreement_id"]["value"] != original["agreement_id"]["value"]:
raise SystemExit(f"amendment-{n}.pdf belongs to a different agreement")
for change in amendment["changes"]["value"]:
changes.append((change["effective_date"]["value"], f"amendment-{n}.pdf", change))
changes.sort(key=lambda item: item[0])
Flag Changes That Need a Person
Some changes can't be applied safely without someone reading the wording. The pipeline should stop and hand the documents to a reviewer, with the page each problem came from, in three cases:
- Two changes target the same section on the same effective date. Neither document says which one wins, and upload order is not an answer.
- A value has a low score. The section number, operation, and date matter as much as the new wording, because a wrong section number changes the wrong clause and a wrong date can leave a change out.
- A replacement or deletion targets a section that is not in force, or an addition targets one that is. The section might be outside the clauses we extracted, or the amendment might point at the wrong number.
Append this check to reconcile.py to catch the first two. It runs on every change, including ones that take effect after the date we reconcile to, so a misread date can't hide a change from review. We check each field's extraction_score against a threshold of 0.9. A deletion's text is an empty string with a low score by design, so we skip it. The third case is checked when each change is applied. The guide to confidence score thresholds explains how to pick a threshold for each field.
seen = {}
for effective, document, change in changes:
section = change["section"]["value"]
if (effective, section) in seen:
raise SystemExit(f"{seen[effective, section]} and {document} both change section {section} on {effective}")
seen[effective, section] = document
fields = [name for name in change
if not (name == "text" and change["operation"]["value"] == "delete")]
if any(change[name]["score"]["extraction_score"] < 0.9 for name in fields):
raise SystemExit(f"Review the change to section {section} in {document}, page {page(change['operation'])}")
Apply the Changes
Append the last snippet to apply each change that has taken effect by a chosen date. A fixed as_of date keeps the output repeatable. A replacement or deletion needs a section that is currently in force, and an addition needs one that isn't, so we stop when either rule breaks. A deleted section stays in terms with its text set to None, which keeps its removal in the history.
as_of = "2026-10-26"
for effective, document, change in changes:
if effective > as_of:
continue
section, operation = change["section"]["value"], change["operation"]["value"]
if (operation == "add") == bool(terms.get(section, {}).get("text")):
raise SystemExit(f"{document}, page {page(change['operation'])}: can't {operation} section {section}")
term = terms.setdefault(section, {"history": []})
term["text"] = None if operation == "delete" else change["text"]["value"]
term["history"].append({"document": document, "operation": operation,
"effective_date": effective, "page": page(change["operation"])})
with open("current-terms.json", "w") as f:
json.dump(terms, f, indent=2)
for section, term in sorted(terms.items()):
print(f"{section} | {term['text'] or '[deleted]'} | {term['history'][-1]['document']}")
print("4.1 history: " + " -> ".join(h["document"] for h in terms["4.1"]["history"]))
Run the full script from the same folder.
python3 reconcile.py
The script prints the terms in force on October 26, 2026, with the document that last set each one.
2.1 | The initial term begins on January 15, 2026 and ends on January 14, 2027. | original.pdf
2.2 | After the initial term, this Agreement renews automatically for successive periods of twelve months. | original.pdf
2.3 | Either party may prevent renewal by giving written notice at least 60 days before the end of the then-current term. | amendment-1.pdf
4.1 | The Customer shall pay the Provider a service fee of USD 10,250 per month. | amendment-3.pdf
5.1 | [deleted] | amendment-3.pdf
5.2 | The Provider shall deliver a written monthly service report to the Customer within five business days after each month ends. | amendment-2.pdf
7.1 | Each party's total liability under this Agreement shall not exceed USD 150,000. | amendment-2.pdf
9.1 | This Agreement is governed by the laws of the State of New York. | original.pdf
4.1 history: original.pdf -> amendment-1.pdf -> amendment-3.pdf
The fee moves from USD 8,000 to USD 9,500, then to USD 10,250 per month. Its history in current-terms.json records each document, operation, effective date, and page, which is the record an extraction audit trail needs. Unchanged terms still point to the original agreement.
This output supports a reviewer rather than replacing legal review.



