Connect any meeting tool to mnueron.
Mnueron Meet ingests transcripts from external meeting tools and turns them into searchable memory. A plugin translates one vendor's shape into the universal MeetingEnvelope. Core does everything else — chunking, embedding, decision extraction, ticket linking, recall. A working connector is usually under 100 lines.
How a plugin fits in
Mnueron Meet is layered. Plugins handle the vendor-facing edge — authentication, payload translation, webhook validation. Core handles everything past the envelope. Plugins never touch the database or the embedder directly. That boundary is what makes plugin code shippable in 100 lines instead of 1,000.
Four lifecycle hooks
Implement whichever hooks make sense for your vendor. Most vendors need exactly one. The upload connector only implements onUpload; the Granola connector only implements onWebhook.
onWebhook
Vendor pushes a notification when a meeting ends
Examples: Granola, Read.ai, Zoom AI Companion, Fathom
onPoll
Vendor has no webhook; we poll on a schedule
Examples: Otter (free tier), Fireflies, some self-hosted recorders
onUpload
User drops a transcript file in the dashboard
Examples: Manual upload connector (first-party)
onEmail
User forwards transcript emails to org's magic address
Examples: Email-forward connector (first-party)
Interface reference
MeetingSource
export interface MeetingSource {
id: string; // 'granola', 'fathom', etc. Used in URLs.
display_name: string; // Shown in the integrations dashboard.
logo?: string; // Path under /public/integrations/
onWebhook?(payload, headers, ctx): Promise<MeetingEnvelope[]>;
pollInterval?: number; // ms, when implementing onPoll
onPoll?(ctx): Promise<MeetingEnvelope[]>;
onUpload?(file, ctx): Promise<MeetingEnvelope>;
onEmail?(email, ctx): Promise<MeetingEnvelope[]>;
}MeetingEnvelope — the one contract
Every hook returns one or more envelopes. The envelope is the only handoff to core; nothing else is preserved.
interface MeetingEnvelope {
source: string; // your plugin id; the registry fills this
source_ref: string; // vendor-stable id; (source, source_ref) is unique
title: string;
started_at: number; // unix ms
duration_seconds: number | null;
attendees: Array<{
name: string;
email?: string;
role?: 'organizer' | 'attendee' | 'invited_absent';
}>;
transcript: Array<{
speaker: string;
text: string;
started_at_ms: number; // ms from meeting start
}>;
summary?: string; // vendor summary if available
raw?: Record<string, unknown>; // verbatim source payload, kept for audit
}PluginContext
Every hook receives a context object scoped to the meeting source instance, with org-scoped storage for cursors and a logger.
interface MeetingPluginContext {
org_id: string;
user_id: string | null;
source_id: string; // your configured meeting_source row id
storage: {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
};
logger: {
info(msg, fields?): void;
warn(msg, fields?): void;
error(msg, err?): void;
};
}Worked example: a Granola connector
A complete, working onWebhook connector for Granola in 60 lines. Validate the signature, fetch the note via their API, translate to envelope. Core does the rest.
import crypto from 'node:crypto';
import type { MeetingSource, MeetingEnvelope, MeetingPluginContext } from 'mnueron/meet';
interface GranolaWebhook { event: string; note_id: string; }
interface GranolaNote {
id: string; title: string; started_at: string;
duration_seconds: number;
participants: Array<{ name: string; email: string }>;
transcript: Array<{ speaker: string; text: string; ms_offset: number }>;
summary?: string;
}
function verify(payload: string, signature: string, secret: string): boolean {
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
export const granolaPlugin: MeetingSource = {
id: 'granola',
display_name: 'Granola',
logo: '/integrations/granola.svg',
async onWebhook(payload, headers, ctx): Promise<MeetingEnvelope[]> {
const secret = await ctx.storage.get<string>('webhook_secret');
if (!secret) throw new Error('granola webhook_secret not configured');
if (!verify(JSON.stringify(payload), headers['x-granola-signature'], secret)) {
throw new Error('invalid signature');
}
const evt = payload as GranolaWebhook;
if (evt.event !== 'note.saved') return [];
const apiKey = await ctx.storage.get<string>('api_key');
const res = await fetch(`https://api.granola.ai/notes/${evt.note_id}`, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) throw new Error(`granola fetch ${res.status}`);
const note = (await res.json()) as GranolaNote;
return [{
source: 'granola',
source_ref: note.id,
title: note.title,
started_at: new Date(note.started_at).getTime(),
duration_seconds: note.duration_seconds,
attendees: note.participants.map(p => ({
name: p.name, email: p.email, role: 'attendee' as const,
})),
transcript: note.transcript.map(t => ({
speaker: t.speaker, text: t.text, started_at_ms: t.ms_offset,
})),
summary: note.summary,
raw: note as unknown as Record<string, unknown>,
}];
},
};That's a shippable connector. Decision extraction, action-item extraction, ticket link parsing, embedding, dedupe, and the dashboard view all come for free. Add the plugin to your registry and configure a webhook endpoint pointing to /api/integrations/meet/granola/webhook.
Best practices
- Make source_ref stable. Re-ingestion of the same meeting should map to the same row. Use the vendor's own id (note_id, recording_id, message_id), not a timestamp.
- Verify webhook signatures. Always, no exceptions. Use timingSafeEqual to prevent timing attacks. The dashboard surfaces signature failures.
- Don't enrich. Resist the urge to add fields not on the envelope. Custom fields belong in
rawfor audit only. - Speaker names matter. Decision and action-item extractors use them to attribute. If your vendor's diarization is shaky, run a normalization pass before emitting the envelope.
- Throw to abort. If a single webhook is malformed, throw. The vendor retries. Don't silently swallow — partial data is worse than missed data.
One interface. Every meeting tool. Forever.
Mnueron ships the popular vendors first-party. The long tail belongs to the community. Build a connector once and your team's meetings land in mnueron alongside everything else they do with AI.