Contract Plain-Language Explainer
Upload a contract and get it explained in plain language — obligations, fees, deadlines, exit clauses, and red flags — with every answer grounded in the actual text, fully in your browser.
Preview Mode
This is a preview with sample data. The template uses placeholders like
which will be replaced with actual agent data.
About This Template
Contract Plain-Language Explainer is a browser-executable AI agent template built on AgentOp. It runs entirely in the browser using Python (via Pyodide) and can be deployed without a server — just download the generated HTML file and open it locally or host it anywhere.
Template Metadata
- Slug
- contract-explainer
- Created By
- ozzo
- Created
- Jul 13, 2026
- Usage Count
- 1
Tags
Code Statistics
- HTML Lines
- 51
- CSS Lines
- 65
- JS Lines
- 118
- Python Lines
- 81
Source Code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ agent_name }}</title>
<style>{{ css_code }}</style>
</head>
<body>
<main class="ce">
<header class="ce__head">
<h1>⚖️ {{ agent_name }}</h1>
<p class="ce__sub">{{ description }}</p>
</header>
<section class="ce__context">
<input id="ce-type" type="text" class="ce__ctx-input"
placeholder="Contract type (e.g. apartment lease)"
aria-label="Contract type">
<input id="ce-role" type="text" class="ce__ctx-input"
placeholder="Your side (e.g. tenant)"
aria-label="Your side of the contract">
</section>
<section class="ce__upload">
<label class="ce__drop" for="contract-file" id="ce-drop">
<input id="contract-file" type="file"
accept=".pdf,.txt,.md,.markdown" multiple hidden>
<span>📜 Upload the contract (PDF or text) — add amendments too</span>
</label>
<div id="ce-status" class="ce__status" role="status"></div>
</section>
<section id="ce-chips" class="ce__chips hidden" aria-label="Suggested questions">
<button type="button" class="ce__chip">What are my main obligations?</button>
<button type="button" class="ce__chip">How do I cancel or exit this contract?</button>
<button type="button" class="ce__chip">What fees or penalties could I face?</button>
<button type="button" class="ce__chip">Any red flags I should worry about?</button>
</section>
<section id="results-container" class="ce__chat" aria-live="polite"></section>
<form id="ce-form" class="ce__form" autocomplete="off">
<input id="ce-input" type="text"
placeholder="Ask about your contract…" required disabled>
<button id="ce-send" type="submit" disabled>Ask</button>
</form>
</main>
<script>{{ js_code }}</script>
</body>
</html>
:root {
color-scheme: light dark;
--ce-primary: #1e3a8a; /* authority navy */
--ce-accent: #b45309; /* restrained trust gold */
--ce-bg: #f8fafc;
--ce-surface: #ffffff;
--ce-text: #0f172a;
--ce-muted: #64748b;
--ce-border: #e2e8f0;
--ce-radius: 12px;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: Georgia, 'Times New Roman', system-ui, serif;
background: var(--ce-bg); color: var(--ce-text); line-height: 1.55; }
.hidden { display: none !important; }
.ce { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.ce__head h1 { font-size: 1.55rem; margin: 0 0 4px; color: var(--ce-primary); }
.ce__sub { margin: 0 0 18px; color: var(--ce-muted);
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.ce__context { display: flex; gap: 8px; margin: 0 0 10px; flex-wrap: wrap; }
.ce__ctx-input { flex: 1 1 220px; padding: 10px 12px; border: 1px solid var(--ce-border);
border-radius: 10px; font: inherit; font-size: .95rem;
background: var(--ce-surface); color: var(--ce-text); }
.ce__ctx-input:focus-visible, .ce__form input:focus-visible
{ outline: 2px solid var(--ce-primary); outline-offset: 1px; }
.ce__drop { display: block; border: 2px dashed #c8d2e4; border-radius: var(--ce-radius);
padding: 22px; text-align: center; cursor: pointer;
background: var(--ce-surface); color: #3c4a63;
transition: border-color .15s, background .15s; }
.ce__drop:hover, .ce__drop--over { border-color: var(--ce-primary); background: #eef3fd; }
.ce__status { font-size: .85rem; color: var(--ce-muted); margin: 10px 2px;
min-height: 1.2em;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.ce__chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 12px; }
.ce__chip { border: 1px solid var(--ce-border); background: var(--ce-surface);
color: var(--ce-primary); border-radius: 999px; padding: 7px 14px;
font-size: .85rem; cursor: pointer; transition: background .15s;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
.ce__chip:hover { background: #eef3fd; }
.ce__chat { display: flex; flex-direction: column; gap: 10px; margin: 8px 0 16px; }
.ce__msg, .message { padding: 11px 14px; border-radius: var(--ce-radius);
max-width: 88%; white-space: pre-wrap; line-height: 1.55; }
.ce__msg--user { align-self: flex-end; background: var(--ce-primary); color: #fff; }
.ce__msg--assistant, .message-assistant { align-self: flex-start;
background: var(--ce-surface); border: 1px solid var(--ce-border); }
.message { display: flex; gap: 8px; }
.message-icon { flex: 0 0 auto; }
.message-content { min-width: 0; }
.ce__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
padding: 12px 16px; background: var(--ce-surface);
border-top: 1px solid var(--ce-border); }
.ce__form input { flex: 1; padding: 12px 14px; border: 1px solid #c9d3e2;
border-radius: 10px; font-size: 1rem;
background: var(--ce-bg); color: var(--ce-text); }
.ce__form button { padding: 0 22px; border: 0; border-radius: 10px;
background: var(--ce-accent); color: #fff; font-weight: 700;
cursor: pointer; }
.ce__form button:disabled { background: #b9c2d0; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
:root { --ce-bg: #101522; --ce-surface: #1a2233; --ce-text: #e7ecf5;
--ce-muted: #9aa7bd; --ce-border: #2b3650; --ce-primary: #93b4f2; }
.ce__drop { color: #b9c6dd; }
.ce__drop:hover, .ce__drop--over { background: #202b42; }
.ce__chip:hover { background: #202b42; }
}
// Contract Explainer UI wiring. The RAG/embeddings/wllama infrastructure is
// injected by the generator; this connects context, upload, chips, and chat.
(function () {
const statusEl = () => document.getElementById('ce-status');
const results = () => document.getElementById('results-container');
const input = () => document.getElementById('ce-input');
const sendBtn = () => document.getElementById('ce-send');
const form = () => document.getElementById('ce-form');
const chips = () => document.getElementById('ce-chips');
const dropZone = () => document.getElementById('ce-drop');
let pyReady = false;
// Render helper the wllama query bridge also calls for streamed responses.
window.addMessage = function (type, content) {
const el = document.createElement('div');
el.className = 'ce__msg ce__msg--' + (type === 'user' ? 'user' : 'assistant');
el.textContent = content;
results().appendChild(el);
el.scrollIntoView({ behavior: 'smooth', block: 'end' });
return el;
};
function setStatus(msg) { statusEl().textContent = msg; }
function enableChat(on) {
input().disabled = !on;
sendBtn().disabled = !on;
}
async function syncContext() {
window.pyodide.globals.set('__ce_type', document.getElementById('ce-type').value.trim());
window.pyodide.globals.set('__ce_role', document.getElementById('ce-role').value.trim());
await window.pyodide.runPythonAsync('_set_context(__ce_type, __ce_role)');
}
async function readFileText(file) {
if (/\.pdf$/i.test(file.name)) {
const buf = new Uint8Array(await file.arrayBuffer());
window.pyodide.globals.set('__ce_pdf_bytes', buf);
return await window.pyodide.runPythonAsync('_extract_pdf_text(bytes(__ce_pdf_bytes.to_py()))');
}
return await file.text();
}
async function ingest(file) {
setStatus('Reading "' + file.name + '"…');
const text = await readFileText(file);
if (!text.trim()) throw new Error('no selectable text found (scanned PDF?)');
await syncContext();
window.pyodide.globals.set('__ce_src', file.name);
window.pyodide.globals.set('__ce_text', text);
const n = await window.pyodide.runPythonAsync('await _ingest_contract(__ce_src, __ce_text)');
setStatus('Indexed ' + n + ' passages from "' + file.name + '". Load a model in the top bar, then ask away.');
chips().classList.remove('hidden');
enableChat(true);
}
async function handleFiles(files) {
if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
for (const file of files) {
try { await ingest(file); }
catch (err) { setStatus('Could not read "' + file.name + '": ' + err); }
}
}
document.getElementById('contract-file').addEventListener('change', (e) => {
handleFiles(e.target.files);
});
dropZone().addEventListener('dragover', (e) => {
e.preventDefault();
dropZone().classList.add('ce__drop--over');
});
dropZone().addEventListener('dragleave', () => {
dropZone().classList.remove('ce__drop--over');
});
dropZone().addEventListener('drop', (e) => {
e.preventDefault();
dropZone().classList.remove('ce__drop--over');
handleFiles(e.dataTransfer.files);
});
chips().addEventListener('click', (e) => {
const chip = e.target.closest('.ce__chip');
if (!chip || input().disabled) return;
input().value = chip.textContent;
form().requestSubmit();
});
form().addEventListener('submit', async (e) => {
e.preventDefault();
const q = input().value.trim();
if (!q || !pyReady) return;
window.addMessage('user', q);
input().value = '';
enableChat(false);
try {
await syncContext();
window.pyodide.globals.set('__ce_q', q);
const answer = await window.pyodide.runPythonAsync('await process_user_query(__ce_q)');
if (answer) window.addMessage('assistant', answer);
} catch (err) {
window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
} finally {
enableChat(true);
input().focus();
}
});
// Pyodide is ready after initAgent(); the orchestrator fires this on document.
document.addEventListener('pyodide-ready', async () => {
// Start each session with an empty index: the RAG store is shared per
// origin, and answers about THIS contract must never come from an earlier
// upload (or another document agent on the same host).
try { await window.AgentOpRAG.clear(); } catch (e) {}
pyReady = true;
setStatus('Ready. Upload your contract to begin — it never leaves this browser.');
});
})();
# How many contract passages to retrieve per question. Deliberately higher than
# a generic document agent: clause-heavy questions (termination, fees, renewal)
# often span several sections, and current local defaults (8K+ token context)
# leave room for the richer grounding.
RAG_TOP_K = [[[RAG_TOP_K|6]]]
# Session context set from the page: what kind of contract, and whose side the
# explanation should take. Both optional.
_CONTEXT = {"contract_type": "", "role": "", "sources": []}
def _set_context(contract_type, role):
"""(JS-callable) Remember the contract type and the user's side.
Underscore-prefixed so it is never exposed to the LLM as a tool.
"""
_CONTEXT["contract_type"] = (contract_type or "").strip()
_CONTEXT["role"] = (role or "").strip()
return "ok"
def _extract_pdf_text(pdf_bytes):
"""(JS-callable) Extract plain text from a PDF's raw bytes via pypdf."""
import io
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(pdf_bytes))
return "\n\n".join((page.extract_text() or "") for page in reader.pages)
async def _ingest_contract(source, text):
"""(JS-callable) Chunk + embed + index one contract document or amendment.
Returns the number of passages stored, as a string (for the UI).
"""
count = await agentop_rag.add_document(source, text)
_CONTEXT["sources"].append(source)
return str(count)
def _framing():
bits = []
if _CONTEXT["contract_type"]:
bits.append(f"The document is a {_CONTEXT['contract_type']}.")
if _CONTEXT["role"]:
bits.append(
f"Explain everything from the perspective of the {_CONTEXT['role']}."
)
return " ".join(bits)
async def process_user_query(query):
"""Plain-language contract answering, grounded in retrieved clauses.
Overrides the default router so retrieval is deterministic (small local
models are unreliable at deciding to call a search tool themselves).
"""
hits = await agentop_rag.search(query, RAG_TOP_K)
if not hits:
return (
"I don't have a contract to read yet — upload it above (PDF or "
"text) and I'll explain it in plain language."
)
excerpts = "\n\n".join(
f"[{i + 1}] (from {h['source']}) {h['text']}" for i, h in enumerate(hits)
)
grounded_prompt = (
"You are explaining a contract to someone who is not a lawyer. "
f"{_framing()} Use ONLY the CONTRACT EXCERPTS below.\n"
"Answer in plain everyday language: give the short answer first, then "
"what the contract actually says, citing excerpt numbers like [1]. "
"If the excerpts mention deadlines, fees, penalties, or automatic "
"renewal, point them out. If the excerpts do not contain the answer, "
"say so plainly instead of guessing.\n\n"
f"CONTRACT EXCERPTS:\n{excerpts}\n\nQUESTION: {query}"
)
# TEMPLATE_SYSTEM_PROMPT only exists as a JS global; the query bridge falls
# back to it when custom_prompt is empty (same idiom as the base template).
return await process_user_query_wllama(
grounded_prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
)
Name your agent
Based on . You'll get your own private copy to try and customize.