Skip to content

Cookbook: Contracts

Contracts are long, structured documents where the granularity matters as much as the values — you usually want clauses you can reference back to a page and bbox, not a flat dictionary.

Long-context legal text is where Anthropic models do well, so the default Worker reaches for claude:

from scriva import Orchestrator, Worker
from scriva.prompts import Prompt
worker = (
Worker.anthropic("claude-opus-4-7")
.prompt(Prompt.contract)
.cache(".scriva_cache")
.score(method="rendering")
)
recipe = (
Orchestrator()
.orient()
.deskew()
.split.boxes(detector="legal-blocks") # heading-aware block segmentation
.classify(roles={"heading": "header", "body": "data"})
.recognize(worker)
.reconstruct.document(sections=True)
.export.json("nda.json")
)
result = recipe("nda.pdf")

recipe.to_dict()["fields"] reads the typed contract: title, parties, clauses with page index and bbox, signature blocks.

class Contract(BaseModel):
title: str
document_type: Literal["NDA", "MSA", "SOW", "Lease", "Employment", "Other"]
parties: list[Party] # role + name + address
effective_date: date
expiry_date: date | None
term_description: str | None
governing_law: str | None
venue: str | None
clauses: list[Clause]
signatures: list[SignatureBlock]
notes: str | None
class Clause(BaseModel):
number: str | None # e.g. "3.2"
heading: str | None
body: str
page_index: int
bbox: BBox
class SignatureBlock(BaseModel):
party_name: str | None
signatory_name: str | None
signatory_title: str | None
signed: bool # detected mark, not a verification
signed_on: date | None
page_index: int
bbox: BBox

Clause.body is a stable identifier you can hash for diffing across revisions; bbox lets a UI jump back to the source.

When you do not need clause bodies — just the metadata at the top of the document — narrow the reconstruct schema:

class ContractHeader(BaseModel):
title: str
parties: list[Party]
effective_date: date
governing_law: str | None
header_recipe = recipe.replace("reconstruct", reconstruct.kv(schema=ContractHeader))
header = header_recipe("nda.pdf").to_dict()["fields"]

Faster, cheaper, and (counterintuitively) more accurate — narrower schemas pressure the prompt to focus.

For contracts that are filled-in copies of a known template, the fastest path is to compute the visual diff and only OCR the changed regions:

from scriva.preprocess import diff_to_template
recipe = (
Orchestrator()
.then(diff_to_template("templates/master-agreement.pdf"))
.split.from_layout("changed.json") # regions emitted by diff_to_template
.recognize(worker)
.reconstruct.document()
.export.json("changed.json")
)
result = recipe("acme-signed.pdf")

diff_to_template rasterises both PDFs at the same DPI, takes the pixel difference, and emits regions for the changed boxes — typically 1–5% of the original document. Combined with worker.cache(...) this brings a 40-page MSA below $0.05.

Bilingual contracts (common in JP, KR, CN procurement) usually have matching clause pairs. Worker.with_language(...) pins a language hint, or set a recipe default:

recipe = recipe.options(default_language="ja")

For per-clause language detection, the default Worker already sets recognition.language (when the engine exposes LANGUAGE_DETECTION); read it from Clause.locale in the reconstructed record.

SignatureBlock.signed is a heuristic — Worker.signature() flags a region with ink in the signature area as True. It is not a signature verifier; it exists so a downstream UI can flag “this looks signed” vs “this looks blank” without rasterising the page itself.

recipe = recipe.recognize.by_kind({
"signature": Worker.signature(),
"text": worker,
})

For real signature verification (Adobe Sign, DocuSign), parse the PDF’s embedded signature dictionary instead — see scriva.pdf.signatures:

from scriva.pdf.signatures import extract_signatures
sigs = extract_signatures("acme-signed.pdf")

That uses the PDF’s CMS layer, not OCR — completely separate code path.

40+ page contracts blow past a single VLM context. The reconstruct step chunks at section boundaries (heading-detected) and stitches the results; you can also stream:

async for page_result in recipe.stream("msa.pdf"):
publish_clauses(page_result.fields.clauses) # incremental UI updates

See Cookbook › Long PDFs for the streaming patterns.

v1 = recipe("contract-v1.pdf")
v2 = recipe("contract-v2.pdf")
from scriva.diff import diff_contracts
report = diff_contracts(v1, v2)
report.added # new clauses
report.removed # dropped clauses
report.changed # (v1_clause, v2_clause) pairs with rewrites
report.to_markdown("redline.md")

scriva.diff.diff_contracts is a thin wrapper over difflib keyed on clause numbers + headings — enough for a redline, not enough for a substitute for human review.

Contracts contain names, addresses, sometimes salary or banking info. Follow the ID cookbook redaction pattern when your Worker is a third-party API.

The cheap pattern for contract review: auto-accept clause bodies, gate every signature block:

recipe = recipe.review.hitl(
when=lambda r: r.region.role == "signature" or (r.confidence or 0) < 0.7,
sidecar="review.json",
)

See Orchestrator › Review for the two-phase variant for web UIs.