Agent Template
Meeting Minutes
Upload a meeting recording and get structured minutes — a summary, key decisions, and action items — plus follow-up Q&A, fully in your browser.
audio
meetings
speech-to-text
summary
productivity
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
Meeting Minutes 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
audio
meetings
speech-to-text
summary
productivity
Template Preview
Template Metadata
- Slug
- meeting-minutes
- Created By
- ozzo
- Created
- Jul 10, 2026
- Usage Count
- 1
Tags
audio
meetings
speech-to-text
summary
productivity
Code Statistics
- HTML Lines
- 37
- CSS Lines
- 37
- JS Lines
- 106
- Python Lines
- 57
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="mm">
<header class="mm__head">
<h1>{{ agent_name }}</h1>
<p class="mm__sub">{{ description }}</p>
</header>
<section class="mm__upload">
<label class="mm__drop" for="audio-file">
<input id="audio-file" type="file" accept="audio/*" multiple hidden>
<span>🎙️ Upload a meeting recording to transcribe it</span>
</label>
<div id="mm-status" class="mm__status" role="status"></div>
<button id="mm-generate" type="button" class="mm__generate" disabled>
📝 Generate minutes
</button>
</section>
<section id="results-container" class="mm__chat" aria-live="polite"></section>
<form id="mm-form" class="mm__form" autocomplete="off">
<input id="mm-input" type="text"
placeholder="Ask a follow-up about the meeting…" required disabled>
<button id="mm-send" type="submit" disabled>Ask</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: #f6f7fb; color: #1a1c24; }
.mm { max-width: 760px; margin: 0 auto; padding: 24px 16px 96px; }
.mm__head h1 { font-size: 1.6rem; margin: 0 0 4px; }
.mm__sub { margin: 0 0 20px; color: #5f6472; }
.mm__drop { display: block; border: 2px dashed #c3c7d9; border-radius: 12px;
padding: 22px; text-align: center; cursor: pointer; background: #fff;
color: #3a3f4d; transition: border-color .15s, background .15s; }
.mm__drop:hover { border-color: #4f46e5; background: #f3f2ff; }
.mm__status { font-size: .85rem; color: #565b69; margin: 10px 2px; min-height: 1.2em; }
.mm__generate { margin: 4px 2px 0; padding: 10px 18px; border: 0; border-radius: 10px;
background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; }
.mm__generate:disabled { background: #b4b6c6; cursor: not-allowed; }
.mm__chat { display: flex; flex-direction: column; gap: 10px; margin: 14px 0 16px; }
.mm__msg, .message { padding: 10px 14px; border-radius: 12px; max-width: 85%;
white-space: pre-wrap; line-height: 1.5; }
.mm__msg--user { align-self: flex-end; background: #4f46e5; color: #fff; }
.mm__msg--assistant, .message-assistant { align-self: flex-start; background: #fff;
border: 1px solid #e6e7ef; }
.message { display: flex; gap: 8px; }
.message-icon { flex: 0 0 auto; }
.message-content { min-width: 0; }
.mm__form { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 8px;
padding: 12px 16px; background: #fff; border-top: 1px solid #e6e7ef; }
.mm__form input { flex: 1; padding: 12px 14px; border: 1px solid #c9ccdb;
border-radius: 10px; font-size: 1rem; }
.mm__form button { padding: 0 20px; border: 0; border-radius: 10px;
background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; }
.mm__form button:disabled { background: #b4b6c6; cursor: not-allowed; }
@media (prefers-color-scheme: dark) {
body { background: #14151b; color: #e8e9f0; }
.mm__drop, .mm__msg--assistant, .message-assistant, .mm__form
{ background: #1d1f27; border-color: #2f313d; }
.mm__form input { background: #14151b; color: #e8e9f0; border-color: #2f313d; }
}
// Meeting Minutes UI wiring. The STT engine + wllama infrastructure are injected
// by the generator; this connects the upload / generate / chat controls to them.
(function () {
const statusEl = () => document.getElementById('mm-status');
const results = () => document.getElementById('results-container');
const input = () => document.getElementById('mm-input');
const sendBtn = () => document.getElementById('mm-send');
const genBtn = () => document.getElementById('mm-generate');
let pyReady = false;
let hasTranscript = false;
// Render helper the wllama query bridge also calls for streamed responses.
window.addMessage = function (type, content) {
const el = document.createElement('div');
el.className = 'mm__msg mm__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;
genBtn().disabled = !on;
}
// Decode any browser-supported audio to mono 16 kHz Float32Array samples.
async function fileToSamples(file) {
const ctx = new AudioContext({ sampleRate: 16000 });
try {
const buf = await ctx.decodeAudioData(await file.arrayBuffer());
if (buf.numberOfChannels === 1) return new Float32Array(buf.getChannelData(0));
const mono = new Float32Array(buf.length);
for (let c = 0; c < buf.numberOfChannels; c++) {
const ch = buf.getChannelData(c);
for (let i = 0; i < buf.length; i++) mono[i] += ch[i] / buf.numberOfChannels;
}
return mono;
} finally {
ctx.close();
}
}
async function transcribe(file) {
setStatus('Decoding "' + file.name + '"…');
const samples = await fileToSamples(file);
setStatus('Transcribing "' + file.name + '"… (first run downloads the model)');
window.pyodide.globals.set('__mm_samples', samples);
window.pyodide.globals.set('__mm_name', file.name);
await window.pyodide.runPythonAsync('await _transcribe_audio(__mm_samples, __mm_name)');
hasTranscript = true;
setStatus('Transcribed "' + file.name + '". Load a model above, then generate ' +
'minutes or ask a question.');
enableChat(true);
}
document.getElementById('audio-file').addEventListener('change', async (e) => {
if (!pyReady) { setStatus('Still starting up—one moment…'); return; }
for (const file of e.target.files) {
try { await transcribe(file); }
catch (err) { setStatus('Could not transcribe "' + file.name + '": ' + err); }
}
});
genBtn().addEventListener('click', async () => {
if (!pyReady || !hasTranscript) return;
window.addMessage('user', 'Generate meeting minutes');
enableChat(false);
try {
const minutes = await window.pyodide.runPythonAsync('await _generate_minutes()');
if (minutes) window.addMessage('assistant', minutes);
} catch (err) {
window.addMessage('assistant', 'Sorry, something went wrong: ' + err);
} finally {
enableChat(true);
}
});
document.getElementById('mm-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 {
window.pyodide.globals.set('__mm_q', q);
const answer = await window.pyodide.runPythonAsync('await process_user_query(__mm_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', () => {
pyReady = true;
setStatus('Ready. Upload a meeting recording to begin.');
});
})();
# Accumulated transcript segments for this meeting: [{"source": name, "text": text}]
TRANSCRIPTS = []
async def _transcribe_audio(samples, name):
"""(JS-callable) Transcribe decoded audio samples and remember the result.
Underscore-prefixed so it is never exposed to the LLM as a tool. ``samples``
is a Float32Array of mono 16 kHz samples handed over by the page JS.
"""
text = (await agentop_ml.transcribe(samples)).strip()
TRANSCRIPTS.append({"source": name, "text": text})
return text
def _transcript_context():
return "\n\n".join(
f"[{i + 1}] (from {t['source']}) {t['text']}"
for i, t in enumerate(TRANSCRIPTS)
)
async def _generate_minutes():
"""(JS-callable) Turn the transcript into structured minutes via wllama."""
if not TRANSCRIPTS:
return "Upload a meeting recording first, then generate the minutes."
prompt = (
"You are given the TRANSCRIPT of a meeting. Produce concise minutes with "
"exactly these three sections, using this markdown:\n"
"## Summary\n(2-4 sentences)\n\n"
"## Key decisions\n(bullet list; write 'None recorded.' if none)\n\n"
"## Action items\n(bullet list of 'owner - task'; 'None recorded.' if none)\n\n"
"Use ONLY what is in the transcript; do not invent names or tasks.\n\n"
f"TRANSCRIPT:\n{_transcript_context()}"
)
return await process_user_query_wllama(
prompt, globals().get("TEMPLATE_SYSTEM_PROMPT", "")
)
async def process_user_query(query):
"""Free follow-up Q&A grounded ONLY in the meeting transcript.
Overrides the default router so context is deterministic (small local
models are unreliable at deciding to call tools themselves).
"""
if not TRANSCRIPTS:
return "No meeting has been transcribed yet - upload a recording first."
grounded_prompt = (
"Answer the QUESTION using ONLY the meeting TRANSCRIPT below. "
"If the answer is not in the transcript, say you could not find it. "
"Cite segment numbers like [1] where relevant.\n\n"
f"TRANSCRIPT:\n{_transcript_context()}\n\nQUESTION: {query}"
)
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.