Serverless Meeting Bots with AWS Lambda and MeetStream

The hardest part of building a meeting automation product isn't the intelligence layer, it's the infrastructure glue. Calendar event fires at 2:58 PM, bot needs to join a 3:00 PM meeting, transcript arrives 20 minutes after the meeting ends, and your system needs to connect all of those dots without polling, without a persistent server you have to babysit, and without dropping events when your app has a bad minute. This is exactly the problem serverless architecture is designed to solve.

A common misconception is that serverless meeting bots means running the bot process itself in Lambda. That doesn't work, Chromium won't fit in Lambda's memory or time constraints. What works is using Lambda for everything around the bot: triggering creation from calendar events, processing webhook callbacks, storing state in DynamoDB, and fetching the transcript after processing completes. The bot runs in MeetStream's managed infrastructure; Lambda handles your orchestration logic.

This pattern gives you true pay-per-use cost (you pay Lambda execution time only when events fire, not for idle server capacity), automatic scaling (Lambda handles bursty calendar events trivially), and no infrastructure to maintain. The tradeoff is cold start latency of 100, 500 ms for Node.js functions, which is acceptable for meeting orchestration since a few hundred milliseconds don't matter for a meeting that starts in 2 minutes.

In this guide, we'll build a complete serverless meeting bot pipeline: calendar trigger Lambda, webhook handler with API Gateway, DynamoDB session state, and transcript retrieval. All code is production-ready with IAM permissions included. Let's get into it.

Architecture Overview

Google Calendar
    │ (EventBridge / Pub/Sub)
    ▼
┌──────────────────┐     ┌─────────────────────────┐
│  Trigger Lambda  │────▶│   MeetStream API         │
│  (create_bot)    │     │   api.meetstream.ai      │
└──────────────────┘     └───────────┬─────────────┘
                                     │
                    ┌────────────────▼───────────────┐
                    │         Bot Session              │
                    │  (joining → inmeeting → stopped) │
                    └────────────────┬───────────────┘
                                     │ webhooks
                                     ▼
┌──────────────────┐     ┌─────────────────────────┐
│  Webhook Lambda  │◀────│   API Gateway            │
│  + DynamoDB      │     │   POST /webhooks         │
└──────────────────┘     └─────────────────────────┘
                    │ (on transcription.processed)
                    ▼
┌──────────────────┐
│  Transcript      │
│  Fetch Lambda    │
└──────────────────┘
AWS serverless service data flows
Common use cases with AWS serverless services and data flows. Source: AWS Architecture Blog.

Each Lambda function has a single responsibility. The trigger creates bots. The webhook handler updates state and routes events. The transcript fetcher pulls and stores the processed transcript.

DynamoDB Schema for Bot Sessions

Before writing Lambda code, set up the DynamoDB table. Partition key is bot_id (string). Add a GSI on calendar_event_id so you can look up which bot corresponds to a calendar event.

# CloudFormation / SAM template excerpt
Resources:
  BotSessionsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: meetstream-bot-sessions
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: bot_id
          AttributeType: S
        - AttributeName: calendar_event_id
          AttributeType: S
      KeySchema:
        - AttributeName: bot_id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: by-calendar-event
          KeySchema:
            - AttributeName: calendar_event_id
              KeyType: HASH
          Projection:
            ProjectionType: ALL
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true

Trigger Lambda: Create Bot on Calendar Event

This Lambda fires when a calendar event starts (or 2, 3 minutes before it, via EventBridge Scheduler). It calls the MeetStream API to create the bot, then writes the session to DynamoDB.

const { DynamoDBClient, PutItemCommand } = require('@aws-sdk/client-dynamodb');
const { SSMClient, GetParameterCommand } = require('@aws-sdk/client-ssm');
const https = require('https');

const dynamo = new DynamoDBClient({ region: process.env.AWS_REGION });
const ssm = new SSMClient({ region: process.env.AWS_REGION });

let _apiKey; // cached per Lambda warm instance

async function getApiKey() {
  if (_apiKey) return _apiKey;
  const resp = await ssm.send(new GetParameterCommand({
    Name: '/meetstream/prod/api-key',
    WithDecryption: true
  }));
  _apiKey = resp.Parameter.Value;
  return _apiKey;
}

function callMeetStreamAPI(apiKey, body) {
  return new Promise((resolve, reject) => {
    const payload = JSON.stringify(body);
    const req = https.request({
      hostname: 'api.meetstream.ai',
      path: '/api/v1/bots/create_bot',
      method: 'POST',
      headers: {
        'Authorization': `Token ${apiKey}`,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(payload)
      }
    }, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(JSON.parse(data));
        } else {
          reject(new Error(`MeetStream API error ${res.statusCode}: ${data}`));
        }
      });
    });
    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

exports.handler = async (event) => {
  const { meeting_url, calendar_event_id, user_id, join_at } = event;

  const apiKey = await getApiKey();

  const bot = await callMeetStreamAPI(apiKey, {
    meeting_link: meeting_url,
    bot_name: 'Notetaker',
    join_at: join_at,
    callback_url: process.env.WEBHOOK_ENDPOINT,
    live_transcription_required: {
      webhook_url: process.env.WEBHOOK_ENDPOINT
    },
    automatic_leave: {
      waiting_room_timeout: 300,
      everyone_left_timeout: 60,
      voice_inactivity_timeout: 600
    }
  });

  // Store session state in DynamoDB
  const now = Math.floor(Date.now() / 1000);
  await dynamo.send(new PutItemCommand({
    TableName: 'meetstream-bot-sessions',
    Item: {
      bot_id: { S: bot.bot_id },
      calendar_event_id: { S: calendar_event_id },
      user_id: { S: user_id },
      meeting_url: { S: meeting_url },
      status: { S: 'Joining' },
      created_at: { N: String(now) },
      ttl: { N: String(now + 86400 * 30) }  // 30-day TTL
    }
  }));

  return { bot_id: bot.bot_id, status: 'dispatched' };
};

Webhook Handler Lambda

This Lambda is behind an API Gateway endpoint. It validates the signature, updates DynamoDB state, and dispatches to the transcript fetcher when a transcript is ready.

AWS Lambda serverless architecture diagram
AWS Lambda serverless architecture for event-driven bots. Source: Medium.
const { DynamoDBClient, UpdateItemCommand } = require('@aws-sdk/client-dynamodb');
const { LambdaClient, InvokeCommand } = require('@aws-sdk/client-lambda');
const crypto = require('crypto');

const dynamo = new DynamoDBClient({ region: process.env.AWS_REGION });
const lambda = new LambdaClient({ region: process.env.AWS_REGION });

exports.handler = async (event) => {
 const body = event.body;
 const signature = event.headers['x-meetstream-signature'];
 const secret = process.env.WEBHOOK_SECRET;

 // Verify signature
 const expected = 'sha256=' + crypto
 .createHmac('sha256', secret)
 .update(Buffer.from(body, event.isBase64Encoded ? 'base64' : 'utf8'))
 .digest('hex');

 if (!crypto.timingSafeEqual(Buffer.from(signature || ''), Buffer.from(expected))) {
 return { statusCode: 401, body: 'Invalid signature' };
 }

 const payload = JSON.parse(
 event.isBase64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body
 );

 // Update session status in DynamoDB
 const statusMap = {
 'bot.joining': 'Joining',
 'bot.inmeeting': 'InMeeting',
 'bot.stopped': payload.bot_status || 'Stopped'
 };

 if (statusMap[payload.event]) {
 await dynamo.send(new UpdateItemCommand({
 TableName: 'meetstream-bot-sessions',
 Key: { bot_id: { S: payload.bot_id } },
 UpdateExpression: 'SET #s = :s, updated_at = :t',
 ExpressionAttributeNames: { '#s': 'status' },
 ExpressionAttributeValues: {
 ':s': { S: statusMap[payload.event] },
 ':t': { N: String(Math.floor(Date.now() / 1000)) }
 }
 }));
 }

 // Dispatch transcript fetcher when transcript is ready
 if (payload.event === 'transcription.processed') {
 await lambda.send(new InvokeCommand({
 FunctionName: process.env.TRANSCRIPT_FETCHER_ARN,
 InvocationType: 'Event', // async invoke
 Payload: JSON.stringify({
 bot_id: payload.bot_id,
 transcript_url: payload.transcript_url
 })
 }));
 }

 // CRITICAL: always return 200, webhooks are not retried on failure
 return { statusCode: 200, body: JSON.stringify({ received: true }) };
};

Transcript Fetch Lambda

This Lambda is invoked asynchronously by the webhook handler when a transcription.processed event arrives. It fetches the transcript from MeetStream and stores it (here, back to DynamoDB, swap for S3 for larger transcripts).

const { DynamoDBClient, UpdateItemCommand } = require('@aws-sdk/client-dynamodb');
const https = require('https');

const dynamo = new DynamoDBClient({ region: process.env.AWS_REGION });

function fetchTranscript(transcriptUrl, apiKey) {
  return new Promise((resolve, reject) => {
    https.get(transcriptUrl, {
      headers: { 'Authorization': `Token ${apiKey}` }
    }, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => resolve(JSON.parse(data)));
    }).on('error', reject);
  });
}

exports.handler = async (event) => {
  const { bot_id, transcript_url } = event;
  const apiKey = process.env.MEETSTREAM_API_KEY;  // or fetch from SSM

  const transcript = await fetchTranscript(transcript_url, apiKey);

  // Store transcript (use S3 for transcripts > 400KB DynamoDB item limit)
  await dynamo.send(new UpdateItemCommand({
    TableName: 'meetstream-bot-sessions',
    Key: { bot_id: { S: bot_id } },
    UpdateExpression: 'SET transcript = :t, transcript_received = :r',
    ExpressionAttributeValues: {
      ':t': { S: JSON.stringify(transcript) },
      ':r': { BOOL: true }
    }
  }));

  return { bot_id, status: 'transcript_stored' };
};

IAM Permissions

Each Lambda function needs minimal IAM permissions. Never use AdministratorAccess for Lambda roles, scope permissions to the specific resources each function touches.

# Trigger Lambda role policy
{
  "Effect": "Allow",
  "Action": [
    "ssm:GetParameter",      # fetch API key
    "dynamodb:PutItem"       # create session record
  ],
  "Resource": [
    "arn:aws:ssm:us-east-1:ACCOUNT:parameter/meetstream/prod/api-key",
    "arn:aws:dynamodb:us-east-1:ACCOUNT:table/meetstream-bot-sessions"
  ]
}

# Webhook Handler Lambda role policy
{
  "Effect": "Allow",
  "Action": [
    "dynamodb:UpdateItem",
    "lambda:InvokeFunction"
  ],
  "Resource": [
    "arn:aws:dynamodb:us-east-1:ACCOUNT:table/meetstream-bot-sessions",
    "arn:aws:lambda:us-east-1:ACCOUNT:function:transcript-fetcher"
  ]
}

How MeetStream Fits

The serverless pattern above works entirely because MeetStream handles the bot execution layer, you never run a browser process in Lambda. The aws lambda meeting bot workflow is: Lambda triggers the MeetStream API, MeetStream runs the bot, MeetStream calls your webhook Lambda, and your code processes the output. You get managed serverless bot infrastructure without writing a single line of Chromium automation. See the full API reference at docs.meetstream.ai.

AWS serverless computing architecture
Serverless architecture for microservices with AWS Lambda. Source: XenonStack.

Conclusion

Serverless and meeting bots are a natural fit when you separate orchestration from execution. Lambda handles triggers, webhook processing, state management, and downstream logic. MeetStream handles the bot process itself. The key implementation details: fetch API keys from SSM Parameter Store with Lambda-role IAM access, cache the key across warm invocations, always return 200 from your webhook Lambda (webhooks are not retried on failure), and use asynchronous Lambda invocation for transcript processing to keep webhook response times fast. Get started free at meetstream.ai.

Frequently Asked Questions

Can I run the meeting bot process itself inside AWS Lambda?

No. Lambda's 10 GB memory limit and 15-minute execution timeout make it unsuitable for Chromium-based bot processes, which need 500 MB, 1 GB RAM per instance and run for the full meeting duration. Use Lambda for orchestration only, triggering bot creation, processing webhooks, storing state and let MeetStream run the actual bot process in managed infrastructure.

How should I handle Lambda cold starts for time-sensitive bot triggers?

Lambda cold starts for Node.js functions are typically 100, 300 ms, which is acceptable for meeting orchestration (bots are scheduled minutes before the meeting). Cache the SSM Parameter Store fetch across warm invocations using a module-level variable. For sub-100 ms requirements, enable Lambda Provisioned Concurrency on the trigger function, though the cost is rarely justified for meeting scheduling workloads.

What DynamoDB read/write capacity do I need for bot session state?

Use PAY_PER_REQUEST billing mode unless you're processing thousands of bot sessions per day. For a 100 sessions/day workload, on-demand is significantly cheaper than provisioned capacity. If you do provision, estimate 1 WCU per bot creation plus 3, 5 WCUs per session (joining, inmeeting, stopped, transcript_received) and size accordingly.

How do I handle the case where a bot never fires the transcription.processed webhook?

Add a DynamoDB TTL-based cleanup job or a scheduled Lambda that queries for sessions in Stopped status without transcript_received: true after a timeout (e.g., 30 minutes). These sessions can be flagged for manual review or re-queued for transcript fetch via a direct API call to MeetStream.

Should I use S3 or DynamoDB to store transcripts?

DynamoDB has a 400 KB item size limit. A one-hour meeting transcript in JSON format can easily exceed this, a 60-minute meeting with 10 participants produces roughly 6,000, 15,000 words, which in a structured JSON format with timestamps and speaker IDs can be 500 KB to 2 MB. Store large transcripts in S3 and save the S3 key reference in DynamoDB rather than the full transcript content.