Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
307 changes: 307 additions & 0 deletions docs/architecture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Arlo — Architecture</title>
<link rel="icon" href="https://www.zoom.com/favicon.ico"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Roboto:wght@300;400;500&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet"/>
<style>
:root{
--ink:#18181b;
--muted:#71717a;
--line:#e4e4e7;
--bg:#fafafa;
--card:#ffffff;
--brand:#2D8CFF;
--brand-tint:#cfe3ff;
}
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:"Inter","Helvetica Neue",Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;}
.page{max-width:1080px;margin:0 auto;padding:32px 24px 40px;}
svg.diagram{display:block;width:100%;height:auto}
</style>
</head>
<body>
<div class="page">
<svg class="diagram" viewBox="0 0 1000 1700" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#18181b"/>
</marker>
<marker id="arrBrand" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#2D8CFF"/>
</marker>
<marker id="arrMuted" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0 0 L10 5 L0 10 z" fill="#71717a"/>
</marker>

<!-- Inline vendor logos so the file is fully self-contained -->
<symbol id="logoZoom" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#2D8CFF"/>
<rect x="13" y="22" width="28" height="20" rx="3" fill="#fff"/>
<path d="M41 27 L52 21 L52 43 L41 37 Z" fill="#fff"/>
</symbol>
<symbol id="logoRtms" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0b5cd6"/>
<circle cx="32" cy="32" r="4" fill="#fff"/>
<path d="M32 20 a12 12 0 0 1 12 12" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<path d="M32 14 a18 18 0 0 1 18 18" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity="0.7"/>
<path d="M32 44 a12 12 0 0 1 -12 -12" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<path d="M32 50 a18 18 0 0 1 -18 -18" fill="none" stroke="#fff" stroke-width="2.6" stroke-linecap="round" opacity="0.7"/>
</symbol>
<symbol id="logoOpenRouter" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#0f1419"/>
<circle cx="20" cy="32" r="3.5" fill="#7c9eff"/>
<circle cx="44" cy="22" r="3.5" fill="#7c9eff"/>
<circle cx="44" cy="42" r="3.5" fill="#7c9eff"/>
<line x1="22.5" y1="30.5" x2="41.5" y2="23.5" stroke="#7c9eff" stroke-width="2"/>
<line x1="22.5" y1="33.5" x2="41.5" y2="40.5" stroke="#7c9eff" stroke-width="2"/>
<path d="M44 16 L50 22 L44 22 Z M44 48 L50 42 L44 42 Z" fill="#7c9eff"/>
</symbol>
<symbol id="logoPostgres" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#336791"/>
<ellipse cx="32" cy="18" rx="16" ry="5" fill="#fff" opacity="0.95"/>
<path d="M16 18 L16 46 Q16 51 32 51 Q48 51 48 46 L48 18" fill="none" stroke="#fff" stroke-width="2.6"/>
<path d="M16 28 Q32 33 48 28" fill="none" stroke="#fff" stroke-width="2"/>
<path d="M16 38 Q32 43 48 38" fill="none" stroke="#fff" stroke-width="2"/>
</symbol>
<symbol id="logoRedis" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#dc382c"/>
<path d="M14 22 L32 16 L50 22 L32 28 Z" fill="#fff"/>
<path d="M14 32 L32 26 L50 32 L32 38 Z" fill="#fff" opacity="0.9"/>
<path d="M14 42 L32 36 L50 42 L32 48 Z" fill="#fff" opacity="0.8"/>
</symbol>
<symbol id="logoNgrok" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#1f1e1e"/>
<path d="M14 40 Q14 22 24 22 Q34 22 34 32 Q34 42 44 42 Q50 42 50 36" fill="none" stroke="#13c463" stroke-width="3.4" stroke-linecap="round"/>
</symbol>
<symbol id="logoUser" viewBox="0 0 80 110">
<circle cx="40" cy="22" r="16" fill="#fff" stroke="#18181b" stroke-width="1.6"/>
<path d="M8 100 Q8 46 40 46 Q72 46 72 100 Z" fill="#fff" stroke="#18181b" stroke-width="1.6"/>
</symbol>

<style><![CDATA[
.t-title{font-family:Inter,sans-serif;font-size:13px;font-weight:600;fill:#18181b}
.t-sub{font-family:Inter,sans-serif;font-size:11px;fill:#71717a}
.t-edge{font-family:Inter,sans-serif;font-size:11px;fill:#71717a}
.t-edge-strong{font-family:Inter,sans-serif;font-size:11px;font-weight:500;fill:#18181b}
.t-panel{font-family:Roboto,Inter,sans-serif;font-size:18px;font-weight:300;letter-spacing:-0.01em;fill:#2D8CFF}
.t-panel-sub{font-family:Inter,sans-serif;font-size:11px;fill:#71717a;text-anchor:middle}
.t-step{font-family:"JetBrains Mono",ui-monospace,monospace;font-size:14px;font-weight:500;fill:#18181b}
.t-desc{font-family:Inter,sans-serif;font-size:11px;fill:#52525b}
.t-note{font-family:Inter,sans-serif;font-size:10px;font-weight:500;fill:#2D8CFF;letter-spacing:0.04em;text-transform:uppercase}
.t-vendor{font-family:Inter,sans-serif;font-size:10px;font-weight:500;fill:#52525b;text-anchor:middle}
.t-h1{font-family:Inter,sans-serif;font-size:22px;font-weight:700;fill:#18181b;text-anchor:middle}
.t-h2{font-family:Inter,sans-serif;font-size:12px;fill:#71717a;text-anchor:middle}
.t-legend{font-family:Inter,sans-serif;font-size:11px;font-weight:500;fill:#18181b}

@keyframes flow { to { stroke-dashoffset: -20; } }
.flow { stroke-dasharray: 6 6; animation: flow 1.4s linear infinite; }
@keyframes flowSlow { to { stroke-dashoffset: -14; } }
.flow-slow { stroke-dasharray: 4 4; animation: flowSlow 1.8s linear infinite; }
]]></style>
</defs>

<!-- ============ TITLE ============ -->
<text x="500" y="34" class="t-h1">Arlo Meeting Assistant — Architecture</text>
<text x="500" y="58" class="t-h2">Zoom RTMS streams live transcript into an embedded Zoom App — no meeting bot required</text>

<!-- ============ USER (top) ============ -->
<g transform="translate(460,84)">
<use href="#logoUser" x="0" y="0" width="80" height="110"/>
<text x="40" y="208" text-anchor="middle" class="t-title">Meeting Participant</text>
<text x="40" y="224" text-anchor="middle" class="t-sub">in a Zoom meeting</text>
</g>

<!-- User ↔ Zoom App (in-meeting embed) -->
<path d="M500 230 L500 270" stroke="#18181b" stroke-width="1.4" fill="none" marker-start="url(#arr)" marker-end="url(#arr)" class="flow"/>
<text x="510" y="248" class="t-edge">opens / interacts with</text>
<text x="510" y="262" class="t-edge">embedded React app</text>

<!-- ============ ZOOM MEETING + ARLO ZOOM APP ============ -->
<g transform="translate(120,278)">
<rect width="760" height="78" rx="0" fill="#fff" stroke="#2D8CFF" stroke-width="1.6"/>
<use href="#logoZoom" x="240" y="14" width="50" height="50"/>
<text x="300" y="34" class="t-panel">Zoom Meeting</text>
<text x="300" y="52" class="t-sub">Embedded Arlo Zoom App · React 18 + Base UI + Zoom Apps SDK</text>
</g>

<!-- Zoom App ↔ Backend (OAuth + WebSocket) -->
<path d="M500 358 L500 408" stroke="#18181b" stroke-width="1.4" fill="none" marker-start="url(#arr)" marker-end="url(#arr)" class="flow"/>
<text x="510" y="378" class="t-edge">OAuth 2.0 (PKCE) +</text>
<text x="510" y="392" class="t-edge">WebSocket transcript stream</text>

<!-- ngrok tunnel (only for local dev) -->
<g transform="translate(60,360)">
<use href="#logoNgrok" x="0" y="0" width="34" height="34"/>
<text x="17" y="48" text-anchor="middle" class="t-vendor">ngrok</text>
<text x="17" y="60" text-anchor="middle" class="t-sub" style="font-size:9px">local dev tunnel</text>
</g>
<path d="M94 376 L 240 376" stroke="#71717a" stroke-width="1.2" stroke-dasharray="4 4" fill="none" marker-end="url(#arrMuted)"/>

<!-- ============ ARLO BACKEND PANEL ============ -->
<g transform="translate(80,420)">
<rect width="840" height="1080" rx="0" fill="#fff" stroke="#2D8CFF" stroke-width="1.6"/>
<use href="#logoZoom" x="288" y="14" width="28" height="28"/>
<text x="324" y="36" class="t-panel">Arlo Backend</text>
<text x="420" y="58" text-anchor="middle" class="t-panel-sub">Node.js + Express + Prisma · RTMS Service · WebSocket broker</text>

<!-- Step 1: oauth_callback -->
<g transform="translate(30,86)">
<rect width="780" height="120" rx="0" fill="#fafafa" stroke="#cfe3ff"/>
<circle cx="40" cy="40" r="16" fill="#2D8CFF"/>
<text x="40" y="46" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">1</text>
<text x="80" y="40" class="t-step">oauth_callback</text>
<text x="80" y="64" class="t-desc">Exchanges Zoom OAuth code for access + refresh tokens;</text>
<text x="80" y="80" class="t-desc">AES-256-GCM encrypts them, persists in Postgres, sets httpOnly cookie.</text>
<text x="80" y="102" class="t-note">on user sign-in</text>
<use href="#logoZoom" x="700" y="14" width="56" height="56"/>
<text x="728" y="98" class="t-vendor">Zoom OAuth</text>
</g>

<!-- Step 2: rtms_webhook -->
<g transform="translate(30,226)">
<rect width="780" height="120" rx="0" fill="#fafafa" stroke="#cfe3ff"/>
<circle cx="40" cy="40" r="16" fill="#2D8CFF"/>
<text x="40" y="46" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">2</text>
<text x="80" y="40" class="t-step">rtms_webhook</text>
<text x="80" y="64" class="t-desc">Receives `meeting.rtms_started` / `meeting.rtms_stopped` events,</text>
<text x="80" y="80" class="t-desc">validates HMAC signature, hands the session off to the RTMS service.</text>
<text x="80" y="102" class="t-note">event subscription</text>
<use href="#logoRtms" x="700" y="14" width="56" height="56"/>
<text x="728" y="98" class="t-vendor">Zoom RTMS</text>
</g>

<!-- Step 3: rtms_stream -->
<g transform="translate(30,366)">
<rect width="780" height="120" rx="0" fill="#fafafa" stroke="#cfe3ff"/>
<circle cx="40" cy="40" r="16" fill="#2D8CFF"/>
<text x="40" y="46" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">3</text>
<text x="80" y="40" class="t-step">rtms_stream</text>
<text x="80" y="64" class="t-desc">Opens an authenticated WebSocket to Zoom RTMS;</text>
<text x="80" y="80" class="t-desc">ingests live transcript frames at &lt; 1 s end-to-end latency.</text>
<text x="80" y="102" class="t-note">root task · long-lived ws</text>
<use href="#logoRtms" x="700" y="14" width="56" height="56"/>
<text x="728" y="98" class="t-vendor">Zoom RTMS</text>
</g>

<!-- Step 4: transcript_pipeline -->
<g transform="translate(30,506)">
<rect width="780" height="120" rx="0" fill="#fafafa" stroke="#cfe3ff"/>
<circle cx="40" cy="40" r="16" fill="#2D8CFF"/>
<text x="40" y="46" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">4</text>
<text x="80" y="40" class="t-step">transcript_pipeline</text>
<text x="80" y="64" class="t-desc">Normalises speaker + timestamp, buffers chunks, batch-inserts to Postgres,</text>
<text x="80" y="80" class="t-desc">then broadcasts deltas over the user's WebSocket back to the Zoom App.</text>
<text x="80" y="102" class="t-note">backend internal</text>
<use href="#logoPostgres" x="700" y="14" width="56" height="56"/>
<text x="728" y="98" class="t-vendor">Postgres</text>
</g>

<!-- Step 5: voice_command -->
<g transform="translate(30,646)">
<rect width="780" height="120" rx="0" fill="#fafafa" stroke="#cfe3ff"/>
<circle cx="40" cy="40" r="16" fill="#2D8CFF"/>
<text x="40" y="46" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">5</text>
<text x="80" y="40" class="t-step">voice_command</text>
<text x="80" y="64" class="t-desc">Detects the "Arlo, …" wake word in the live transcript; routes to</text>
<text x="80" y="80" class="t-desc">summarise · action_items · highlight · decisions · send-to-chat.</text>
<text x="80" y="102" class="t-note">wake-word router</text>
<use href="#logoOpenRouter" x="700" y="14" width="56" height="56"/>
<text x="728" y="98" class="t-vendor">OpenRouter</text>
</g>

<!-- Step 6: ai_insights -->
<g transform="translate(30,786)">
<rect width="780" height="120" rx="0" fill="#fafafa" stroke="#cfe3ff"/>
<circle cx="40" cy="40" r="16" fill="#2D8CFF"/>
<text x="40" y="46" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">6</text>
<text x="80" y="40" class="t-step">ai_insights</text>
<text x="80" y="64" class="t-desc">Calls a free Gemini / Llama model via OpenRouter to produce summaries,</text>
<text x="80" y="80" class="t-desc">action items, decisions, open questions — streamed inline to the app.</text>
<text x="80" y="102" class="t-note">openrouter · free tier default</text>
<use href="#logoOpenRouter" x="700" y="14" width="56" height="56"/>
<text x="728" y="98" class="t-vendor">OpenRouter</text>
</g>

<!-- Step 7: search_and_chat -->
<g transform="translate(30,926)">
<rect width="780" height="120" rx="0" fill="#fafafa" stroke="#cfe3ff"/>
<circle cx="40" cy="40" r="16" fill="#2D8CFF"/>
<text x="40" y="46" text-anchor="middle" font-size="14" font-weight="700" fill="#fff">7</text>
<text x="80" y="40" class="t-step">search_and_chat</text>
<text x="80" y="64" class="t-desc">Full-text search across every saved transcript;</text>
<text x="80" y="80" class="t-desc">RAG-style chat using Postgres FTS results as context for OpenRouter.</text>
<text x="80" y="102" class="t-note">post-meeting</text>
<use href="#logoPostgres" x="700" y="14" width="56" height="56"/>
<text x="728" y="98" class="t-vendor">Postgres FTS</text>
</g>

<!-- Vertical chain arrows + data labels -->
<g stroke="#2D8CFF" stroke-width="1.4" fill="none" marker-end="url(#arrBrand)" class="flow">
<path d="M420 206 L420 226"/>
<path d="M420 346 L420 366"/>
<path d="M420 486 L420 506"/>
<path d="M420 626 L420 646"/>
<path d="M420 766 L420 786"/>
<path d="M420 906 L420 926"/>
</g>
<text x="432" y="222" class="t-edge" fill="#2D8CFF">access_token</text>
<text x="432" y="362" class="t-edge" fill="#2D8CFF">rtms.started</text>
<text x="432" y="502" class="t-edge" fill="#2D8CFF">transcript frames</text>
<text x="432" y="642" class="t-edge" fill="#2D8CFF">caption deltas</text>
<text x="432" y="782" class="t-edge" fill="#2D8CFF">intent + window</text>
<text x="432" y="922" class="t-edge" fill="#2D8CFF">stored transcripts</text>
</g>

<!-- Retry loop: ai_insights → voice_command (routed outside the panel on the right) -->
<g stroke="#2D8CFF" stroke-width="1.6" fill="none" stroke-dasharray="5 4">
<path d="M 890 1276 L 950 1276 L 950 1086 L 890 1086" marker-end="url(#arrBrand)"/>
</g>
<text x="958" y="1178" class="t-note">retry</text>
<text x="958" y="1192" class="t-edge" fill="#2D8CFF">on llm fail</text>

<!-- Backend ↔ Postgres -->
<path d="M420 1500 L420 1540" stroke="#18181b" stroke-width="1.4" fill="none" marker-start="url(#arr)" marker-end="url(#arr)"/>
<text x="432" y="1524" class="t-edge">read / write</text>

<!-- Backend ↔ Redis (optional) -->
<path d="M620 1500 L620 1540" stroke="#71717a" stroke-width="1.2" stroke-dasharray="4 4" fill="none" marker-start="url(#arrMuted)" marker-end="url(#arrMuted)"/>
<text x="632" y="1524" class="t-edge">pub/sub</text>

<!-- ============ POSTGRES (DB cylinder) ============ -->
<g transform="translate(220,1550)">
<path d="M 0 12 L 0 72 A 200 10 0 0 0 400 72 L 400 12"
fill="#fff" stroke="#2D8CFF" stroke-width="1.6"/>
<ellipse cx="200" cy="12" rx="200" ry="10"
fill="#fafafa" stroke="#2D8CFF" stroke-width="1.6"/>
<use href="#logoPostgres" x="118" y="26" width="28" height="28"/>
<text x="154" y="46" class="t-panel" style="font-size:16px">Postgres</text>
<text x="200" y="64" text-anchor="middle" class="t-sub">transcripts · highlights · summaries · encrypted Zoom tokens</text>
</g>

<!-- ============ REDIS (optional cylinder) ============ -->
<g transform="translate(640,1550)">
<path d="M 0 12 L 0 72 A 90 10 0 0 0 180 72 L 180 12"
fill="#fff" stroke="#71717a" stroke-width="1.4" stroke-dasharray="4 4"/>
<ellipse cx="90" cy="12" rx="90" ry="10"
fill="#fafafa" stroke="#71717a" stroke-width="1.4" stroke-dasharray="4 4"/>
<use href="#logoRedis" x="42" y="22" width="28" height="28"/>
<text x="78" y="42" class="t-panel" style="font-size:14px;fill:#71717a">Redis</text>
<text x="90" y="60" text-anchor="middle" class="t-sub" style="font-size:10px">optional · WebSocket scaling</text>
</g>

<!-- ============ LEGEND ============ -->
<g transform="translate(40,1640)">
<rect width="280" height="50" rx="6" fill="#fff" stroke="#e4e4e7"/>
<line x1="14" y1="20" x2="54" y2="20" stroke="#18181b" stroke-width="1.4" marker-end="url(#arr)"/>
<text x="62" y="24" class="t-edge">request / control</text>
<line x1="14" y1="38" x2="54" y2="38" stroke="#2D8CFF" stroke-width="1.4" marker-end="url(#arrBrand)" stroke-dasharray="6 6" class="flow"/>
<text x="62" y="42" class="t-edge">live event / stream</text>
</g>
</svg>
</div>
</body>
</html>
Loading