Build a workplace agent that uses Zoom meeting and chat context to recommend follow-up actions without removing human oversight. This guide shows developers how to build an agentic workflow that observes what happened, recommends the next steps, requests approval, and takes action only after approval is provided.
What you'll need:
- RTMS access (pricing ). RTMS requires a paid Zoom Workplace plan with the appropriate entitlement.
- A Zoom Developer Account and a General OAuth App created in the Zoom App Marketplace .
- A backend that can receive webhooks, join the RTMS media stream, store meeting context, and call an LLM.
- A Zoom App that displays the dashboard and provides in-meeting controls for starting and stopping RTMS.
- An OpenAI API key for task extraction, meeting summaries, and Zoom Doc creation through the Responses API and Zoom MCP .
- An Anthropic API key for the Zoom Chat Research Assistant bot.
- ngrok . A free static domain is recommended so your Marketplace URLs do not change each time ngrok restarts.
Features:
- Live transcription (Observe): Stream meeting transcripts through RTMS using the
meeting.rtms_startedandmeeting.rtms_stoppedlifecycle webhooks. Transcript segments are sent to the browser over server-sent events (SSE) for a live in-meeting view. - AI task extraction (Recommend): After the meeting ends, OpenAI analyzes the full transcript and uses Structured Outputs with a strict JSON Schema to return predictable task suggestions.
- Meeting intelligence: Generate a meeting summary and identify decisions, risks, and blockers alongside the suggested tasks.
- Human-in-the-loop approvals (Approve): Each extracted task begins as a
pendingsuggestion. The user can approve, edit, or reject it. Only approved suggestions become tasks. - AI Actions: Follow-up Docs (Execute): When the meeting contains enough useful context, the app recommends creating a follow-up document. The user can preview and edit the AI-generated Markdown before approving it. The backend then uses the OpenAI Responses API with Zoom MCP as a remote tool source to create a Zoom Doc. This action requires the
docs:write:importscope. - Share to Zoom Chat: Post the created Zoom Doc link to the current chat using the Zoom Apps SDK’s
sendMessageToChatAPI. This requires theimchat:userappscope and must run inside the Zoom client.
Architecture

Implementation Guide
This blueprint shows how live Zoom Workplace context can become a human-in-the-loop completion workflow.
A meeting produces a transcript. The transcript becomes structured signals. Those signals produce recommended actions. The user reviews and approves an action, and a Zoom capability executes the approved work.
The code follows a clear separation of responsibilities:
- AI observes and proposes.
- Application logic decides when to recommend.
- A human approves.
- Zoom developer tools execute.
AI never mutates application state or calls a Zoom execution surface on its own. Extracted tasks enter the system as pending suggestions, and generated artifacts are previewed before they can be created.
Observe meeting context with RTMS
The workflow starts when Zoom fires the meeting.rtms_started webhook.
A unified webhook handler routes the event into the RTMS lifecycle. The same POST /webhooks endpoint also handles Zoom Chat events, with RTMS handled as another event branch.
The webhook provides the information needed to join the media stream:
meeting_uuidrtms_stream_idserver_urls
The transcript itself arrives later over the RTMS media connection.
backend/routes/webhook-routes.js
case 'meeting.rtms_started':
case 'webinar.rtms_started': {
const o = payload?.object ?? payload ?? {};
const meetingId = o.meeting_uuid ?? o.webinar_uuid ?? o.session_id;
const { rtms_stream_id, server_urls } = o;
if (meetingId && rtms_stream_id && server_urls) {
startRtmsSession(meetingId, rtms_stream_id, server_urls).catch(err =>
log.error('startRtmsSession error', { error: err.message })
);
} else {
log.warn(`${event} missing required fields`, { payload: o });
}
break;
}
The webhook ACKs 200 immediately and dispatches the handler separately so slower AI or Zoom Chat calls do not interfere with Zoom's three-second webhook timeout .
meeting.rtms_stopped mirrors this branch and calls stopRtmsSession(meetingId).
startRtmsSession opens a @zoom/rtms client. Once the client joins, the application explicitly enables transcript delivery.
backend/utils/rtms/rtms-service.js
// The native binding requires the boolean: enableTranscript(true).
// Calling with no argument throws "Boolean argument expected".
client.enableTranscript(true);
Transcript frames arrive through onTranscriptData. This callback is where the raw RTMS buffer becomes an application-level transcript segment.
backend/utils/rtms/rtms-service.js
// onTranscriptData(buffer, size, timestamp, metadata)
client.onTranscriptData((buffer, _size, timestamp, metadata) => {
handleTranscriptSegment(meetingUuid, {
userId: metadata?.userId,
userName: metadata?.userName,
text: buffer.toString('utf8'),
seqNo: timestamp,
tStartMs: timestamp,
tEndMs: null,
confidence: null,
});
});
The SDK timestamp also acts as the segment deduplication key.
Preserve meeting identity across the workflow
Every downstream stage uses the Zoom meeting UUID.
When RTMS joins, registerMeetingContext() upserts a meeting record into the shared TaskStore. Each transcript segment is mirrored into that store so AI analysis, the dashboard, and the recommendation layer can read the same meeting context without depending directly on the live RTMS connection.
backend/utils/rtms/transcript-handler.js
function mirrorToTaskStore(meetingUuid, segment) {
taskStore.appendTranscriptSegment(meetingUuid, {
ts: new Date(segment.tStartMs || Date.now()).toISOString(),
speaker: segment.speakerLabel,
text: segment.text,
});
}
At the same time, live segments are sent to the browser over SSE:
GET /api/meetings/:meetingId/transcript-stream
This keeps the in-progress meeting view updated while RTMS continues collecting context.
Let Zoom lifecycle events control processing
RTMS events also determine when processing begins and ends.
meeting.rtms_started begins capture.
When RTMS stops, the SDK's onLeave path calls cleanupSession, which starts post-meeting analysis.
The application does not run the full analysis continuously during the meeting. It waits until the transcript is complete and then processes the meeting once.
backend/utils/rtms/post-meeting-ai.js
if (analyzedMeetings.has(meetingUuid)) {
return {
suggestionCount: 0,
summarized: false,
reason: 'already_analyzed'
};
}
analyzedMeetings.add(meetingUuid);
const transcript = taskStore.getTranscript(meetingUuid);
if (!transcript.length) {
return {
suggestionCount: 0,
summarized: false,
reason: 'no_transcript'
};
}
The meeting lifecycle itself becomes the trigger:
RTMS started → collect context
RTMS stopped → analyze completed context
There is no separate timer or manual "run analysis" step.
Analyze context and generate recommendations
The recommendation pipeline has two layers.
AI interprets language and produces structured signals.
Application logic decides whether those signals justify recommending an action.
Turn the transcript into structured suggestions
The completed transcript is sent to an analyzer using OpenAI Structured Outputs with JSON Schema and strict: true.
The model must return a predictable task structure rather than free-form text that the application would need to interpret again.
backend/utils/ai/ai-task-analyzer.js
const response = await openai.chat.completions.create({
model: options.model ?? DEFAULT_MODEL,
temperature: 0.2,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userMessage },
],
response_format: {
type: 'json_schema',
json_schema: TASK_EXTRACTION_SCHEMA
},
});
Each extracted task includes fields such as:
- the task
- confidence
- a verbatim
sourceExcerpt - the person associated with the commitment
The system prompt tells the model:
"Only extract tasks that a specific person committed to… Do NOT invent tasks."
The analyzer returns structured data to its caller. It does not create Zoom Tasks or persist external changes.
backend/utils/rtms/post-meeting-ai.js
const saved = extraction.tasks.map(t =>
taskStore.saveTaskSuggestion(t)
);
saveTaskSuggestion() stores each result with:
status: 'pending'
That status is important. AI extraction creates something the user can review before execution is possible.
Decide when to surface an action
A rule-based recommender looks at the meeting's accumulated signals and decides whether the UI should recommend a follow-up action.
backend/utils/ai/follow-up-recommender.js
const approvedTasks =
tasks.filter(t => t.source === 'ai_suggestion').length;
const pendingSuggestions =
suggestions.filter(s => s.status === 'pending').length;
const completedTasks =
tasks.filter(t => t.status === 'completed').length;
const blockers =
countBlockers(risks, ctx.summary);
const hasSummary =
Boolean(ctx.summary && ctx.summary.trim().length > 20);
The model interprets what people said.
The recommender evaluates application state.
That keeps workflow triggers inspectable and gives the UI concrete reasons for a recommendation, such as:
2 approved tasks · 1 decision · 1 blocker
GET /api/meetings/:id/ai-actions returns a create-follow-up-doc suggestion only when shouldSuggestDoc evaluates to true.
The reasons array travels with the recommendation so the user can see what caused it to appear.
Put execution behind an approval boundary
Previewing an action and executing an action use separate API operations.
The preview endpoint generates editable Markdown:
POST /api/meetings/:id/ai-actions/preview
It does not create anything in Zoom. After reviewing or editing the content, the user can call:
POST /api/meetings/:id/ai-actions/create-doc
The execution endpoint validates that approved Markdown was provided before continuing.
backend/routes/api/ai-actions.js
const { markdown, title } = req.body ?? {};
if (!markdown || typeof markdown !== 'string' || !markdown.trim()) {
return res.status(400).json({
success: false,
error: 'markdown is required. Call /ai-actions/preview first.',
});
}
// The user OAuth token comes from the session populated by /auth/callback.
// If absent the user must sign in before creating a Doc.
const userAccessToken =
req.session?.zoomTokens?.access_token ?? null;
This puts the approval requirement in backend application logic instead of relying on the UI alone.
AI-extracted tasks use the same pattern.
POST /api/task-suggestions/:id/approve
A pending suggestion becomes an executable task only after the approval endpoint is called.
Execute approved work with Zoom Workplace MCP
After the user approves the Markdown, the backend creates a Zoom Doc through Zoom Workplace MCP.
The sample uses the OpenAI Responses API as the MCP client rather than implementing the Zoom MCP protocol directly.
backend/utils/zoom/zoom-mcp-docs.js
response = await openai.responses.create({
model,
tools: [{
type: 'mcp',
server_label: 'zoom-mcp',
server_url: endpoint,
// OpenAI forwards these headers when it talks to Zoom MCP.
// The user OAuth token must include `docs:write:import`.
headers: {
Authorization: `Bearer ${userAccessToken}`
},
// Approval already happened in the application UI.
require_approval: 'never',
// Restrict this execution path to the expected Zoom MCP tool.
allowed_tools: [TOOL_NAME],
}],
});
The configuration controls three parts of execution. server_url points to Zoom's MCP endpoint:
https://mcp.zoom.us/mcp/zoom/streamable
headers.Authorization forwards the user's OAuth bearer token. The Doc is therefore created under that user's authorization and requires the appropriate docs:write:import scope.
allowed_tools limits this execution path to:
create_new_file_with_markdown
The model cannot select another Zoom MCP tool exposed by the server.
require_approval: 'never' applies to the MCP invocation because approval has already happened in the application before this code runs.
After execution, the backend parses the returned file_id and file_link, associates the Doc with the meeting, and sends the result back to the client.
Keep the workflow inside Zoom with the Apps SDK
The dashboard runs as a Zoom App inside the Zoom client.
The implementation connects three Zoom surfaces:
- A Zoom Chat card launches the app.
- The Apps SDK handles in-client context and authorization.
- The Apps SDK sends the completed result back into Zoom Chat.
Launch the app from a Zoom Chat card
The Zoom Chat bot builds an interactive card containing an Open Dashboard button.
The button uses:
action: 'dialog'
with a dialog.link, which opens the Next.js application in a Zoom webview.
backend/utils/cards/menu-cards.js
{
type: 'button',
text: 'Open Dashboard',
value: ACTIONS.OPEN_DASHBOARD,
style: 'Default',
action: 'dialog',
dialog: {
size: 'L',
title: { text: 'AI Services Dashboard' },
link: dashboardUrl,
},
}
dashboardUrl comes from getWebviewUrl().
The helper accepts only HTTPS URLs because dialog.link must be an HTTPS URL to open in Zoom’s in-client browser. If it cannot produce one, it returns null and the card builder leaves the button out instead of sending an invalid card.
backend/utils/zoom/zoom-app-urls.js
for (const raw of candidates) {
const trimmed = String(raw).replace(/\/+$/, '');
if (trimmed.startsWith('https://')) {
return `${trimmed}/`;
}
}
return null;
Once the webview loads, useSurface() checks the context reported by the Apps SDK and maps it to the appropriate UI.
frontend/lib/use-surface.tsx
function deriveKind(
phase: SdkPhase,
context: string | null
): SurfaceKind {
if (phase === 'browser' || phase === 'detecting') return 'browser';
if (context === 'inMeeting' || context === 'inWebinar') return 'meeting';
if (context === 'inTeamChat') return 'team-chat';
if (context === 'inMainClient') return 'main-client';
return 'browser';
}
The Zoom Chat bot and Apps SDK have separate jobs here.
-
The card launches the webview from chat.
-
The Apps SDK runs inside that webview and handles client context, authorization, and sharing.
Handle authorization inside the Zoom client
Creating a Zoom Doc requires a user OAuth token with docs:write:import.
If create-doc returns 401 authRequired while the Apps SDK is available, the client starts the in-client OAuth flow and retries the same creation request once.
frontend/components/AIActions.tsx
let result = await api.createFollowUpDoc(
meetingId,
{ markdown: editedMd, title }
);
if (
!result.success &&
'authRequired' in result &&
result.authRequired &&
sdk.phase === 'ready'
) {
setPhase('authorizing');
const authResult = await sdk.startInClientAuth();
if (!authResult.ok) {
setCreateErr(
`In-client sign-in failed: ${authResult.reason}`
);
setPhase('preview-ready');
return;
}
setPhase('creating');
result = await api.createFollowUpDoc(
meetingId,
{ markdown: editedMd, title }
);
}
The client and backend coordinate through a small PKCE handshake.
The backend generates a verifier and state, stores them in the session, and gives the client a codeChallenge.
Zoom's in-client flow uses the plain PKCE method.
backend/routes/oauth-routes.js
const verifier =
crypto.randomBytes(48).toString('base64url');
const state =
crypto.randomBytes(16).toString('hex');
req.session.inClientOAuth = {
state,
verifier,
createdAt: Date.now()
};
log.info('in-client OAuth started', { state });
return res.json({
success: true,
state,
codeChallenge: verifier,
codeChallengeMethod: 'plain'
});
The client sends the challenge to zoomSdk.authorize(...), waits for onAuthorized, and POSTs the returned code to:
/auth/in-client/callback
The backend performs the PKCE token exchange and stores the resulting tokens in:
req.session.zoomTokens
The browser redirect flow uses the same session location, so downstream Zoom MCP execution does not need separate logic for browser OAuth and in-client OAuth.
If the Apps SDK is unavailable, the browser surface can still fall back to /auth/login.
The SDK capabilities are declared when the application initializes.
frontend/lib/use-zoom-app-sdk.ts
const CAPABILITIES = [
'startRTMS',
'stopRTMS',
'pauseRTMS',
'resumeRTMS',
'getRTMSStatus',
'onRTMSStatusChange',
'getRunningContext',
'getMeetingContext',
'sendMessageToChat',
'authorize',
'onAuthorized',
];
Send the completed work back to Zoom Chat
After the Doc is created, the client can post its link into the user's current Zoom Chat context using the App SDK sendMessageToChat method.
frontend/components/AIActions.tsx
const sdkResult = await sdk.sendMessageToChat(message);
if (!sdkResult.ok) {
setShareNote(sdkResult.reason);
setPhase('created');
return;
}
try {
await api.markFollowUpDocShared(
meetingId,
createdDoc.id
);
The backend endpoint records that the share occurred. It does not post the Zoom Chat message.
backend/routes/api/ai-actions.js
// Frontend calls this after the user shares the doc to chat via the Apps SDK.
// We don't actually post the message from the backend; the Apps SDK does that
// client-side. This endpoint just records the intent for the activity log.
This separation follows the context available on each side of the application.
The backend has the user's OAuth token and can create the Zoom Doc through MCP.
The frontend is running inside the Zoom client and can send a message into the user's current chat context.
End-to-end implementation pattern
OBSERVE
meeting.rtms_started
↓
@zoom/rtms joins the session
↓
onTranscriptData
MEETING CONTEXT
Normalized transcript segments
stored by meeting UUID
↓
TaskStore + live SSE updates
ANALYZE
meeting.rtms_stopped
↓
OpenAI Structured Outputs
↓
Task suggestions saved as `pending`
RECOMMEND
Deterministic application rules
↓
"Create Follow-up Doc"
↓
Reasons shown to the user
PREVIEW
POST /ai-actions/preview
↓
Editable Markdown
↓
No Zoom side effect
APPROVE
User reviews or edits
↓
User confirms the action
↓
Backend validates approved content
EXECUTE
OpenAI Responses API
↓
Zoom Workplace MCP
↓
allowed_tools + user OAuth
↓
Zoom Doc created
BACK TO WORK
Apps SDK
↓
sendMessageToChat
↓
Doc link posted into Zoom Chat
The implementation keeps three boundaries explicit:
Reasoning: AI converts meeting language into structured suggestions.
Authorization: the user decides which suggestion becomes an action.
Execution: Zoom RTMS, MCP, APIs, and the Apps SDK perform specific operations using the context and authorization available to them.
That gives developers a reusable pattern for Zoom Workplace applications:
Observe → Recommend → Approve → Execute
The model can interpret context and prepare work, but application code still controls when an action becomes executable and which Zoom capability is allowed to perform it.
App Manifest
See 0-app-manifest it contains app-manifest.json (scopes, chatbot subscription, events, webview, and redirect URIs already configured) plus a short guide to importing it in the Zoom App Marketplace. Replace example.ngrok.app with your tunnel URL, upload, and your app is configured.
