Agent Template
Semantic Search
Paste or upload text and search it by meaning, not keywords — instant results in your browser on a 25 MB model, no LLM or big download needed.
search
embeddings
semantic
retrieval
privacy
ozzo
Jul 10, 2026
1 use
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
Semantic Search 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.
Topics
search
embeddings
semantic
retrieval
privacy
Template Preview
Template Metadata
- Slug
- semantic-search
- Created By
- ozzo
- Created
- Jul 10, 2026
- Usage Count
- 1
Tags
search
embeddings
semantic
retrieval
privacy
Code Statistics
- HTML Lines
- 38
- CSS Lines
- 31
- JS Lines
- 75
- Python Lines
- 33
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="ss">
<header class="ss__head">
<h1>{{ agent_name }}</h1>
<p class="ss__sub">{{ description }}</p>
</header>
<section class="ss__ingest">
<textarea id="ss-text" class="ss__text"
placeholder="Paste text to search over (notes, docs, an FAQ…)"></textarea>
<div class="ss__ingest-row">
<button id="ss-index" type="button" class="ss__btn">Index text</button>
<label class="ss__file" for="ss-file">or upload a .txt / .md file
<input id="ss-file" type="file" accept=".txt,.md,.markdown" multiple hidden>
</label>
</div>
<div id="ss-status" class="ss__status" role="status"></div>
</section>
<section id="results-container" class="ss__results" aria-live="polite"></section>
<form id="ss-form" class="ss__form" autocomplete="off">
<input id="ss-input" type="text"
placeholder="Search your text semantically…" required disabled>
<button id="ss-search" type="submit" disabled>Search</button>
</form>
</main>
<script>{{ js_code }}</script>
</body>
</html>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: #f5f8f7; color: #16211d; }
.ss { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.ss__head h1 { font-size: 1.6rem; margin: 0 0 4px; }
.ss__sub { margin: 0 0 20px; color: #56655f; }
.ss__text { width: 100%; min-height: 110px; padding: 12px 14px; border-radius: 12px;
border: 1px solid #c6d6d0; font: inherit; resize: vertical; background: #fff;
color: #16211d; }
.ss__ingest-row { display: flex; align-items: center; gap: 14px; margin: 10px 0 0; }
.ss__btn { padding: 9px 16px; border: 0; border-radius: 10px; background: #0f9d78;
color: #fff; font-weight: 600; cursor: pointer; }
.ss__file { font-size: .9rem; color: #0f9d78; cursor: pointer; }
.ss__status { font-size: .85rem; color: #56655f; margin: 10px 2px; min-height: 1.2em; }
.ss__results { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 16px; }
.ss__hit { padding: 10px 14px; border-radius: 12px; background: #fff;
border: 1px solid #e0eae6; white-space: pre-wrap; line-height: 1.5; }
.ss__hit b { color: #0f9d78; }
.ss__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
padding: 12px 16px; background: #fff; border-top: 1px solid #e0eae6; }
.ss__form input { flex: 1; padding: 12px 14px; border: 1px solid #cbd8d3;
border-radius: 10px; font-size: 1rem; }
.ss__form button { padding: 0 20px; border: 0; border-radius: 10px; background: #0f9d78;
color: #fff; font-weight: 600; cursor: pointer; }
.ss__form button:disabled { background: #a9bcb6; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
body { background: #121a17; color: #e6efeb; }
.ss__text, .ss__hit, .ss__form { background: #1b241f; border-color: #2c362f; color: #e6efeb; }
.ss__form input { background: #121a17; color: #e6efeb; border-color: #2c362f; }
}
// Semantic Search UI wiring. The embeddings + RAG store are injected by the
// generator; this connects the index + search controls to them. No chat model.
(function () {
const statusEl = () => document.getElementById('ss-status');
const results = () => document.getElementById('results-container');
const input = () => document.getElementById('ss-input');
const searchBtn = () => document.getElementById('ss-search');
let pyReady = false;
function setStatus(msg) { statusEl().textContent = msg; }
function enableSearch(on) {
input().disabled = !on;
searchBtn().disabled = !on;
}
async function ingest(source, text) {
if (!text.trim()) return;
setStatus('Indexing "' + source + '"…');
window.pyodide.globals.set('__ss_src', source);
window.pyodide.globals.set('__ss_text', text);
const n = await window.pyodide.runPythonAsync('await _ingest(__ss_src, __ss_text)');
setStatus('Indexed ' + n + ' passages from "' + source + '". Search away!');
enableSearch(true);
}
document.getElementById('ss-index').addEventListener('click', async () => {
if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
try { await ingest('pasted text', document.getElementById('ss-text').value); }
catch (err) { setStatus('Could not index text: ' + err); }
});
document.getElementById('ss-file').addEventListener('change', async (e) => {
if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
for (const file of e.target.files) {
try { await ingest(file.name, await file.text()); }
catch (err) { setStatus('Could not index "' + file.name + '": ' + err); }
}
});
document.getElementById('ss-form').addEventListener('submit', async (e) => {
e.preventDefault();
const q = input().value.trim();
if (!q || !pyReady) return;
enableSearch(false);
try {
window.pyodide.globals.set('__ss_q', q);
const out = await window.pyodide.runPythonAsync('await process_user_query(__ss_q)');
results().innerHTML = '';
const heading = document.createElement('div');
heading.className = 'ss__status';
heading.textContent = 'Results for "' + q + '":';
results().appendChild(heading);
for (const block of out.split('\n\n')) {
const el = document.createElement('div');
el.className = 'ss__hit';
el.textContent = block;
results().appendChild(el);
}
} catch (err) {
setStatus('Search failed: ' + err);
} finally {
enableSearch(true);
}
});
// Pyodide is ready after initAgent(); the orchestrator fires this on document.
document.addEventListener('pyodide-ready', async () => {
// Start from an empty index each session — this is an ad-hoc "search what
// you paste now" tool, and the RAG store is shared per origin.
try { await window.AgentOpRAG.clear(); } catch (e) {}
pyReady = true;
setStatus('Ready. Paste or upload text to search over — no AI model needed.');
});
})();
# How many passages to return per search.
SEARCH_TOP_K = [[[SEARCH_TOP_K|5]]]
async def _ingest(source, text):
"""(JS-callable) Index text as individual passages, one per non-empty line.
Per-line indexing keeps each note / FAQ entry / list item its own searchable
unit (rather than merging a short blob into one chunk), so ranking is
meaningful. Underscore-prefixed so it is never exposed to the LLM as a tool.
Returns the number of passages stored, as a string (for the UI).
"""
passages = [line.strip() for line in text.splitlines() if line.strip()]
total = 0
for passage in passages:
total += await agentop_rag.add_document(source, passage)
return str(total)
async def process_user_query(query):
"""Return the most semantically similar passages — no LLM generation.
Retrieval is deterministic and runs entirely on the embedding model, so this
template never loads a chat model.
"""
hits = await agentop_rag.search(query, SEARCH_TOP_K)
if not hits:
return "No matches yet — index some text first, then search."
lines = []
for i, h in enumerate(hits):
score = round(float(h["score"]) * 100)
lines.append(f"[{i + 1}] {score}% match · {h['source']}\n{h['text']}")
return "\n\n".join(lines)
Name your agent
Based on . You'll get your own private copy to try and customize.