Back to docs
Plugins · Meeting sources

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.

# your plugin code
Vendor webhook / API / file
MeetingEnvelope ← the only contract
# core takes over here
↓ dedupe + chunk + embed
↓ decision extractor
↓ action item extractor
↓ ticket / commit link extractor
meeting + decisions + action_items + links

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 raw for 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.