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", "")
    )