pattern-ai-labs/join-meeting
> AgentCall (agentcall.dev) — Join a video meeting (Google Meet, Teams, Zoom) as an AI bot with voice and visual presence. Supports audio-only mode with voice intelligence (barge-in, interruptions), text-to-speech mode, and webpage modes for custom UI. Use when asked to join a call, attend a meeting, or participate in a video conference.
npx skills add https://github.com/pattern-ai-labs/agentcall --skill join-meeting
IMPORTANT: Read this entire document before joining a meeting. This file
contains the CALL_LOOP algorithm (mandatory), active participation rules,
safety requirements (leave/cleanup), and mode-specific guidance. Skipping
sections will result in broken meeting experiences — the user will be left
talking to silence.
**IMPORTANT: Read the whole document on every session, not just the parts you
remember.** This skill is updated frequently — new commands, new events, new
recommended patterns (like the event-driven tail -f + Monitor flow in
"How to read events") are added often. Do NOT rely on what you remember from
previous sessions. Re-read this document each time you start a meeting so you
pick up the latest guidance. If unsure whether you are on the latest version,
run python scripts/python/check_update.py (see "Checking for Skill Updates").
Join a video meeting as an AI bot with voice and visual presence.
pip install aiohttp websocketscd scripts/node && npm installBefore joining a meeting, make sure an API key is configured:
~/.agentcall/config.json — if it exists and has api_key, you're ready.AGENTCALL_API_KEY env var — if set, you're ready.Option A — Self-register via email (no dashboard, no human hand-off needed).
AgentCall accounts are created with a 6-digit email code. New accounts include
free trial credits (base plan, 360 minutes), so the first call works immediately.
# 1. Request a code. Use the user's email, or — if you can read your own
# mailbox — an address you have access to:
python scripts/python/register.py send --email [email protected]
# 2. Get the 6-digit code from that inbox. If you can read the mailbox yourself,
# retrieve it directly; otherwise ask the user to paste it. Codes expire in
# 10 minutes; a resend is allowed after 60 seconds.
# 3. Verify the code. This mints an API key named "AgentCall Skill on <hostname>"
# and saves it to ~/.agentcall/config.json:
python scripts/python/register.py verify --email [email protected] --code 123456
Node equivalent: node scripts/node/register.js send --email ... and
node scripts/node/register.js verify --email ... --code .... Both scripts use
only the language standard library, so they run before pip install / npm install.
Option B — Use an existing key. Ask the user for their API key
(create one at https://app.agentcall.dev/api-keys), then save it:
mkdir -p ~/.agentcall
cat > ~/.agentcall/config.json << 'EOF'
{"api_key": "USER_KEY_HERE"}
EOF
The scripts (bridge.py, join.py, agentcall.py, register.py) automatically read
from ~/.agentcall/config.json if AGENTCALL_API_KEY env var is not set.
Do NOT ask for the API key every session — check the config file first.
Meeting transcripts arrive as agent input — any participant in the call
can therefore steer the agent. For high-trust workflows, configure your
agent framework's permission system (e.g., Claude Code's allow allowlist,
hooks, plan mode) to restrict what the agent can do during a call. The
skill defers to the framework's enforcement. Recommended for use in
trusted meetings or properly scoped projects.
First-call detection: if ~/.agentcall/config.json has no default_mode
field saved, treat this as the user's first call.
First call (no default_mode in ~/.agentcall/config.json): new
accounts include free trial credits. Offer the user a brief "experience
call" with --mode webpage-av-screenshare --voice-strategy direct so
they can see the full feature set — the pattern avatar (default),
screenshare, interactive webpages, and voice with barge-in. If they
prefer a simpler mode, honor that. After the call ends, ask which mode
to save as the default going forward.
After the first call ends (in the agent conversation, not the meeting):
Ask the user which mode to save as their default going forward. Present as a
numbered list:
webpage-av-screenshare — everything on tap (avatar + screenshare + webpage sharing)webpage-av — avatar only, no screensharewebpage-audio — audio from a webpage into the meetingaudio — voice only, simplestOffer to explain any option if the user wants clarification. Mention they can
see real-world examples at https://www.youtube.com/@pattern-ai-labs.
Save the choice to ~/.agentcall/config.json:
{
"api_key": "ak_ac_xxxxx",
"default_mode": "webpage-av-screenshare",
"default_voice_strategy": "direct",
"default_voice": "af_heart",
"default_bot_name": "Juno"
}
audio mode", respect it for that call without updating the saved default.
Only update the default if the user says "always use this" or "make this
my default."
request always takes priority over saved preferences.
gets the first-call demo and the post-call prompt.
./scripts/run.sh <meet-url> [options]
| Option | Default | Description |
|--------|---------|-------------|
| --mode | audio | audio (voice only, simplest), webpage-audio (audio from webpage), webpage-av (visual avatar), webpage-av-screenshare (avatar + screenshare). See Modes Explained below. |
| --voice-strategy | direct | collaborative, direct |
| --bot-name | Agent | Display name in the meeting participant list |
| --port | 3000 | Local port for webpage modes (your UI server) |
| --screenshare-port | 3001 | Local port for screenshare content |
| --template | pattern | Built-in UI: pattern (default, radial sunburst with per-state colors and the work-in-progress task list), ring (neon ring), orb, avatar, dashboard, blank, voice-agent (no local server needed) |
| --transcription | on | Real-time transcript.final and transcript.partial events. Required for most workflows. Disable with --no-transcription to save STT billing if you only need lifecycle events. |
| --trigger-words | | Comma-separated aliases for collaborative mode: june,juno,hey june |
| --context | | Initial context for voice intelligence (max 4000 chars) |
| --webpage-url | | Public URL for webpage modes (no tunnel needed) |
| --screenshare-url | | Public URL for screenshare content (no tunnel needed) |
| --max-duration | plan limit | Max call duration in minutes. Cannot exceed your plan's limit. Check https://agentcall.dev for current limits. |
| --alone-timeout | 120 | Leave if alone for N seconds. |
| --silence-timeout | 300 | Leave if silent for N seconds. |
| --api-url | https://api.agentcall.dev | Override API URL for development |
Choose STT-friendly names — short, distinctive, real-sounding words that
speech-to-text can reliably capture. Avoid generic phrases like "AI Assistant"
or "Hey Bot" — transcription often garbles these.
Good names: Juno, June, Nova, Sage, Atlas, Claude, Aria, Echo
Avoid: AI Assistant, My Bot, Hey Agent, Assistant Bot
Always set trigger words in collaborative mode to cover STT mishearings:
--bot-name "Juno" --trigger-words "juno,june,you know,junior"
--bot-name "Claude" --trigger-words "claude,cloud,clod,clawed"
--bot-name "Nova" --trigger-words "nova,no va,over"
The display name in the participant list can be longer (e.g., "Juno - AI Assistant")
but the trigger words should be the short phonetic variants that STT might produce.
Voice only. Bot has no video. Best for: AI assistants, note-takers, voice agents.
No local server needed. Simplest setup.
Your local webpage provides audio. Bot's video is black. The webpage can play audio
that meeting participants will hear. Best for: audio-only web apps.
Requires: --port pointing to your local HTTP server.
If your webpage is publicly hosted, pass --webpage-url https://your-site.com/bot
instead of --port. No tunnel or local server needed.
Your webpage IS the bot's video feed — what renders on the page is what meeting
participants see as the bot's camera. Audio from the page is also captured into
the meeting. The page is loaded once and runs continuously. All updates must come
via WebSocket events from your agent — it does not auto-refresh.
Best for: animated avatars, branded visual presence, agent-controlled dynamic UIs.
The webpage can also be a standalone voice-to-voice agent: it receives the meeting's
audio as microphone input, processes it with its own AI backend, and replies through
the browser's speaker — which FirstCall (meeting infrastructure) captures into the meeting. This means any
existing voice agent webpage can join meetings with zero modification.
Keep it simple. The agent controls the page via WebSocket. The page renders what
the agent tells it to. Use --template orb or --template avatar for built-in options.
For slides or screen-sharing content, use webpage-av-screenshare instead.
Same as webpage-av PLUS the ability to screenshare. Bot has two visual presences:
screenshare.startScreenshare starts inactive. The bot joins with only the avatar visible.
Screenshare activates when the agent sends screenshare.start with a URL or port.
If you don't need screenshare at all, use webpage-av mode instead.
Bot has two visual presences when screenshare is active:
Meeting audio is routed ONLY to the avatar page (not screenshare). Audio from
both pages is captured into the meeting.
Agent controls screenshare dynamically during the call:
screenshare.start with url — share a public URL: {"command": "screenshare.start", "url": "https://slides.google.com/..."}screenshare.start with port — share a local server via tunnel: {"command": "screenshare.start", "port": 3001}screenshare.stop — stop sharing: {"command": "screenshare.stop"}screenshare.swap — atomically swap to a different page: {"command": "screenshare.swap", "port": 3002} or {"command": "screenshare.swap", "url": "https://..."}. Use this instead of stop+start when changing what's shared during a call — it serializes the stop and waits for FirstCall to confirm before starting the new share, which avoids races and the "old content keeps showing" bug.Requires: --port AND --screenshare-port (local), or --webpage-url AND
--screenshare-url (public, no tunnel).
IMPORTANT — screenshare is a live, agent-controlled canvas:
Once loaded, the screenshare page cannot be clicked, scrolled, or typed into by
anyone — it runs in a headless browser. The agent controls what's on screen by
updating files or API responses on its local server — the page polls for changes
via HTTP (every 2 seconds) through the tunnel and re-renders automatically.
Design for 1280x720 viewport. FirstCall's headless browser renders at this
resolution. Use large fonts (40px+ for headings, 24px+ for body text) so content
is readable in the meeting participant's screenshare view.
Live screenshare pattern — for slides, dashboards, or any dynamic content:
/state.json every 2sstate.json file that holds the current state (e.g., {"slide": 0})python -m http.serverport — tunnel proxies HTTP to your localhoststate.json — the page picks it up within 2sAgent: "Let me show you the Q3 numbers."
→ agent creates /tmp/screenshare/index.html + state.json
→ agent starts: python -m http.server 3001 --directory /tmp/screenshare/
→ agent sends: {"command": "screenshare.start", "port": 3001}
Agent: "Moving to the next slide."
→ agent writes: echo '{"slide": 1}' > /tmp/screenshare/state.json
→ page polls, detects change, renders slide 2
Agent: "Here's the revenue chart."
→ agent writes: echo '{"slide": 2}' > /tmp/screenshare/state.json
→ page renders the chart slide
This makes the screenshare a real-time visual companion to the agent's voice,
fully synchronized — the agent narrates while updating files that control what
everyone sees. No WebSocket needed — all updates flow via HTTP through the tunnel.
See Webpage AV Screenshare Guide for full HTML snippet and examples.
Bonus feature — share an interactive webpage with participants. This mode
also supports webpage.open, which exposes a page from your localhost via a
shareable URL. Participants open it in their own browser (fully interactive —
clickable, scrollable, can type and submit forms). This is NOT a screenshare
(headless, in-meeting only) — it is a shareable link the agent builds and
sends to participants. Ideal for agent-generated dashboards, reports, forms,
interactive code diffs, and any content you want participants to actually
click. The tunnel closes automatically when the call ends. See "Sharing a
live webpage" under Pattern 5 for commands, events, and the full workflow.
| Need | Mode | Why |
|------|------|-----|
| Voice only, no video | audio | Simplest. No webpage, no tunnel. |
| Audio from a webpage | webpage-audio | Webpage plays audio into meeting. |
| Visual avatar/brand | webpage-av | Your page = bot's camera feed. |
| Avatar + might screenshare | webpage-av-screenshare | Avatar always on. Screenshare on demand. |
Rule of thumb: For first-time users (no default_mode in
~/.agentcall/config.json), always use webpage-av-screenshare to showcase
the full experience — see User Preferences section for the first-call demo
flow. For returning users, use their saved default_mode. In general,
start with audio if no preference is known. Add webpage-av if you need
visual presence. Add webpage-av-screenshare only if the agent will share
content (slides, charts, demos) during the call. Screenshare is always
dynamic — activated via screenshare.start command, not at call creation.
Need participants to interact with something (not just see it)? Use
webpage-av-screenshare mode and the webpage.open command. The agent serves
a page from its localhost; participants open the shareable URL in their own
browser — clickable, scrollable, fillable. Different from screenshare (which
is a headless view only). Examples: a form to collect meeting feedback, a
dashboard participants can drill into, a code diff viewer. See "Sharing a
live webpage" in Pattern 5 for commands and workflow.
For webpage modes, AgentCall creates a secure tunnel from the cloud to your localhost:
--template which starts one automatically).You do NOT need to expose your machine to the internet. The tunnel handles it.
When using --template, the bridge starts a local server and tunnel automatically — no manual setup.
When using --webpage-url (public URL), no tunnel is needed — FirstCall loads it directly.
Port conflicts: Before starting a local server on a specific port, verify it's available: lsof -i :PORT. If another process (e.g., Node.js on port 3000) is already bound, the tunnel will proxy to the wrong server, causing unexpected 404 errors. Use a different port or use --template which auto-selects a free port.
When creating a call with ui_port, the API response includes:
tunnel_id — unique identifier for this tunneltunnel_access_key — per-call credential for tunnel authenticationtunnel_url — the public URL where FirstCall loads your pageThe tunnel client registers with the server using tunnel_id + tunnel_access_key.
IMPORTANT: The tunnel_access_key is NOT your API key (ak_ac_...). It is a separate, per-call credential generated specifically for tunnel authentication. Using your API key will fail with an error message explaining the correct credential to use. If using bridge-visual.py, this is handled automatically.
In all webpage modes, FirstCall (meeting infrastructure) automatically grants microphone permission to your
page. Your webpage receives the meeting's audio as browser microphone input.
Important: Your page MUST start mic recording automatically on load — no button
clicks, no user interaction. FirstCall (meeting infrastructure) loads your page in the rendering environment and cannot
interact with UI elements. Use navigator.mediaDevices.getUserMedia({ audio: true })
on page load or in a script that runs immediately.
// Auto-start mic on page load (required for voice agent webpages)
window.addEventListener('load', async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
// stream now has meeting audio — process it with your AI
});
Meeting audio is routed to the main avatar page only. In screenshare mode,
the screenshare page does NOT receive mic input.
Uses GetSun as a speech intelligence layer — it handles real-time voice timing
(trigger words, barge-in, interruptions) so the agent doesn't need sub-second
response times. But the agent is still the brain: it provides context, injects
data, triggers responses, and does all the thinking. GetSun is the mouth, not the mind.
The bot:
Configuration (collaborative field in call creation):
| Param | Default | Description |
|-------|---------|-------------|
| trigger_words | [] | Alternate names (handles STT mishearing): ["june", "juno"] |
| barge_in_prevention | true | Wait for silence before speaking |
| interruption_use_full_text | true | Use full text during interruptions for smarter responses |
| context | "" | Initial knowledge scratchpad (4000 chars max) |
| voice | voice.heart | TTS voice: voice.heart, voice.bella, voice.echo, voice.eric |
Your agent receives transcript.final events only (not partials — GetSun (collaborative voice intelligence) handles those).
Use the context-first pattern:
voice.context_update with the datatrigger.speak with the original questionAnnouncing background task results:
voice.context_update with the full results datainject.natural with a SHORT announcement: "I've got the results" (1 sentence only)See Collaborative Mode Guide for detailed examples, context patterns, and full working code.
GetSun responds in <1 second — the agent will NEVER beat it to a response.
This means the agent's job is to prepare GetSun's context in advance so
GetSun says the right thing when a participant speaks.
1. Pre-load capabilities on join:
Immediately after call.bot_ready, send a voice.context_update that tells
GetSun what the agent can do. This is the most important step — without it,
GetSun will refuse requests the agent could handle.
voice.context_update: "You are [bot_name], an AI assistant in this meeting.
You have a backend agent that can: search code, edit files, run commands,
check deployments, browse documentation, analyze data, and more.
When asked to do something:
- If you know the answer from context, respond directly.
- If you need to check or look something up, say 'Let me check on that'
or 'Sure, give me a moment.' The agent will handle the work and provide
results for you to speak.
- NEVER say 'I can't do that' or 'I don't have access to that.'
The agent behind you has the tools — always defer to it."
Customize the capabilities list based on what your agent framework can actually
do (code search, web browsing, API calls, file editing, etc.).
2. GetSun will always speak first:
When a participant asks something:
User: "Juno, what's the deployment status?"
GetSun (instant, from context): "Sure, let me check on that for you."
Agent (sees transcript.final): runs health check → gets result
Agent: sends voice.context_update with full deployment data
Agent: sends inject.natural "I've got the deployment status." (short announcement)
GetSun: speaks the announcement at next pause
User: "What's the status?" → GetSun answers from context instantly
The pattern is always: GetSun buys time → Agent does work → Agent updates context + announces.
3. Handle capability mismatches:
If GetSun says "I can't do that" (because the context didn't cover this capability):
voice.text with GetSun's refusalvoice.context_update adding the missing capabilityinject.natural with a correction: "Actually, I can help with that. Let me take a look."4. Keep context fresh:
After completing any task, update GetSun's context with the results:
Agent completes deployment check →
voice.context_update: "Latest deployment status: all services healthy,
last deploy 2 hours ago, 3 pods running, 0 errors in last 30 min."
Now if a participant asks a follow-up ("Any errors?"), GetSun answers instantly
from context without the agent needing to run another check.
5. Ongoing conversation awareness:
The agent should monitor voice.text events (what GetSun said) AND
transcript.final events (what participants said). This gives the agent
full awareness of the conversation — both sides. Use this to:
6. Predictive context updates:
The agent can preemptively update GetSun's context with data relevant to the
current discussion — BEFORE anyone asks. This makes GetSun answer instantly
(<1s) instead of deferring ("let me check") and waiting for the agent.
Participants discussing deployment...
Agent: (I have deployment data) → context_update with deployment status
User: "Juno, is the deployment healthy?"
GetSun: answers instantly from context — no delay, no "let me check"
When to preload context:
context_update is silent — GetSun absorbs it without speaking. If nobody asks
about the preloaded topic, no harm done. If someone does ask, GetSun answers
instantly. The 4000-char limit means the agent must prioritize — preload data
most relevant to the current discussion, not everything it knows.
No voice intelligence. Your agent controls everything:
transcript.final — completed utterancestranscript.partial — in-progress transcription (someone is still talking)active_speaker — who's talkingYour agent decides when to speak using:
tts.speak — AgentCall TTS (54 voices, 9 languages, <1s latency)audio.inject — raw PCM 16kHz 16-bit mono (your own audio pipeline)You are responsible for turn-taking and timing.
Available TTS voices (direct mode):
| Voice ID | Name | Language | Gender |
|----------|------|----------|--------|
| af_heart | Heart | en-us | Female |
| af_bella | Bella | en-us | Female |
| af_sarah | Sarah | en-us | Female |
| af_nicole | Nicole | en-us | Female |
| am_adam | Adam | en-us | Male |
| am_michael | Michael | en-us | Male |
| bf_emma | Emma | en-gb | Female |
| bf_isabella | Isabella | en-gb | Female |
| bm_george | George | en-gb | Male |
| bm_lewis | Lewis | en-gb | Male |
Voice ID convention: {language}{gender}_{name} — af_ = American Female,
am_ = American Male, bf_ = British Female, bm_ = British Male.
For the full list of all available voices, query GET /v1/tts/voices.
Collaborative mode voices (GetSun, different naming from direct): voice.heart (F), voice.bella (F), voice.echo (M), voice.eric (M). Direct mode voices (Kokoro TTS) use af_heart, am_adam etc. — these are different systems, names are NOT interchangeable.
Barge-in prevention: in direct mode, the bridge automatically holds
tts.speak until the human finishes speaking — you don't need to gate
it yourself. If the human keeps speaking for more than 10 seconds while
the bot has queued speech, the bridge politely raises the bot's hand
(and in webpage modes, flips the avatar to "waiting_to_speak") so
participants see the bot has something to say. In collaborative mode,
GetSun handles all of this.
For 1:1 conversations (customer support, interviews, tutoring):
Use direct mode and respond to every transcript.final. The bridge handles
barge-in prevention automatically — just send tts.speak and the bridge will
wait for silence before delivering the audio. Tips:
tts.speaktranscript.final is NOT dropped during bot speech — if a user speaks while the botis talking, you will receive their message
Interruption handling (webpage modes 2-4, direct mode):
In webpage modes, interruption is automatic but debounced — a single
transcript.partial doesn't cut the bot off (too easily triggered by mic
noise, brief fillers, or acknowledgments like "mhm"). The webpage pauses
the bot's audio on the first partial, flips the avatar to "interrupted"
(red) for immediate visual feedback, and waits up to 2 seconds for
sustained speech (about 2 spoken words). If sustained, the audio is cleared
and tts.interrupted is delivered to the agent with played and not_played
sentence lists:
{
"event": "tts.interrupted",
"reason": "user_speaking",
"played": ["Hello there.", "How are you?"],
"not_played": ["I was about to ask...", "...something important."]
}
played — sentences the participant heard in fullnot_played — sentences cut mid-way OR queued but never startedIf the wait window expires without sustained speech, the bot resumes
playing from where it paused and the avatar flips back to "speaking" —
false alarm, no tts.interrupted fires.
On confirmed interruption the avatar stays "interrupted" until the next
state-changing event takes over (typically auto-thinking when
user.message arrives, or the bot's next tts.speak).
The agent decides what to do based on the lists:
(Collaborative mode interruption — GetSun-driven — is unchanged: tts.audio_clear from the backend bypasses the debounce.)
Interruption in audio mode (mode 1):
No automatic interruption — the bot's audio is injected directly into FirstCall.
The agent still receives transcript.final during bot speech and can send audio.clear
to stop playback. See Interruption Handling Guide.
Each line is a JSON object.
Event key convention: Lifecycle events use the "event" field. Transcription, meeting, and media events use the "type" field. Always check both: event.get("event") or event.get("type") (Python) or event.event || event.type (JS). Tip: bridge.py normalizes all events to use the "event" field — if using join.py directly, always check both fields.
Startup time: After creating a call, the bot takes 30-90 seconds to join the meeting (varies by platform — Google Meet is fastest, Teams/Zoom can take longer). During this time you'll see lifecycle events: call.created → call.bot_joining → call.bot_joining_meeting → call.bot_ready. The agent MUST wait patiently and NOT timeout or assume failure during this window. If using bridge.py, it handles this automatically — the agent simply waits for the first user.message or greeting.prompt event.
{"event": "call.created", "call_id": "call-xxx", "ws_url": "wss://...", "status": "bot_joining"}
{"event": "call.tunnel_ready", "call_id": "call-xxx"}
{"event": "call.bot_joining", "call_id": "call-xxx"}
{"event": "call.bot_joining_meeting", "call_id": "call-xxx", "detail": "starting"}
{"event": "call.bot_joining_meeting", "call_id": "call-xxx", "detail": "joining"}
{"event": "call.bot_joining_meeting", "call_id": "call-xxx", "detail": "initializing"}
{"event": "call.bot_waiting_room", "call_id": "call-xxx"}
{"event": "call.bot_ready", "call_id": "call-xxx"}
{"event": "call.ended", "call_id": "call-xxx", "reason": "meeting_ended", "duration_minutes": 45}
{"event": "call.state", "call_id": "call-xxx", "status": "bot_ready", "mode": "audio", "voice_strategy": "collaborative", "created_at": "..."}
{"event": "call.transcript_ready", "call_id": "call-xxx", "transcript_url": "...", "expires_at": "..."}
{"event": "call.max_duration_warning", "call_id": "call-xxx", "minutes_remaining": 5}
{"event": "call.credits_low", "call_id": "call-xxx", "balance_microcents": 500000, "estimated_minutes_remaining": 12}
{"event": "call.degraded", "reason": "voice_disconnected"}
{"event": "call.recovered", "reason": "voice_reconnected"}
call.bot_joining means AgentCall has requested the bot — the meeting infrastructure is spinning up. call.bot_joining_meeting means the bot is actively entering the meeting (launching browser, navigating to URL, initializing audio). The detail field shows the sub-step: starting → joining → initializing. These fire in rapid succession — no action needed, just progress indicators.
call.max_duration_warning fires 5 minutes before the call reaches its plan's max duration limit.
Default action: create a new call for the same meeting URL. The new bot joins while the
old one is still active. When the old bot leaves (max_duration), the new bot continues seamlessly.
Your session context is preserved — no information is lost. The concurrent limit allows one
extra call during this 5-minute grace window so the rejoin works even on the base plan.
If the user is on the base plan (1hr max), mention that Pro offers 4hr max duration —
upgrade at https://app.agentcall.dev/add-credits ($29/mo subscription or recharge $50+ for 30 days of Pro).
call.credits_low fires at call start if the user's credit balance is below $1. Includes
balance_microcents (raw balance) and estimated_minutes_remaining (calculated from current
call's mode and add-on rates). The agent should inform the user and suggest recharging at
https://app.agentcall.dev/add-credits. Credits low does NOT terminate the call — the call
continues and credits can go negative. This is a courtesy warning, not a cutoff.
call.degraded means a backend service disconnected (e.g., voice intelligence). The bot is still in the meeting and transcripts still flow. In collaborative mode, voice commands (inject.natural, trigger.speak) may not work until call.recovered. In direct mode, tts.speak is unaffected. No action needed — the system auto-recovers.
call.state is sent on every WS connect/reconnect — use it to restore agent state after crash recovery.
call.bot_waiting_room means the meeting has a lobby — the bot is waiting to be admitted by the host. Do NOT send any commands (tts.speak, send_chat, etc.) during this state — no one will hear or see them and they are not queued.
IMPORTANT: Even after call.bot_ready, wait for at least one participant.joined event before sending any commands. If the bot is admitted but no participants have joined yet, the bot is alone in the meeting — no one will hear what it says. Always wait for a participant before speaking or sending data.
{"type": "transcript.final", "text": "What do you think about Q3?", "speaker": {"id": "p-1", "name": "Alice"}, "timestamp": "2026-03-25T10:05:23.456Z"}
{"type": "transcript.partial", "text": "What do you thi", "speaker": {"id": "p-1", "name": "Alice"}, "timestamp": "2026-03-25T10:05:22.100Z"}
Note: transcript.partial in direct mode only. Includes speaker.id, speaker.name, and timestamp.
{"type": "participant.joined", "participant": {"id": "p-1", "name": "Alice"}, "participants": [{"id": "p-1", "name": "Alice"}]}
{"type": "participant.left", "participant": {"id": "p-2", "name": "Bob"}, "participants": [{"id": "p-1", "name": "Alice"}]}
{"type": "active_speaker", "speaker": {"id": "p-1", "name": "Alice"}}
{"type": "chat.message", "sender": "Alice", "message": "Can everyone hear me?", "message_id": "msg-123"}
{"event": "voice.state", "state": "listening"}
{"event": "voice.text", "text": "The revenue was 2.4 million dollars."}
7 states (collaborative mode only — GetSun (collaborative voice intelligence)):
| State | Meaning |
|-------|---------|
| listening | Default — hearing the conversation, not engaged |
| actively_listening | Trigger word detected, capturing the full question |
| thinking | Processing a response |
| waiting_to_speak | Response ready, waiting for silence (barge-in prevention) |
| speaking | Speaking via TTS |
| interrupted | Someone talked over the bot, stopped speaking |
| contextually_aware | Just responded — actively monitoring conversation for follow-up questions or related discussion. Lasts ~20 seconds after speaking. |
voice.text shows each sentence the bot is speaking (for agent awareness).
{"event": "tts.started", "destination": "meeting"}
{"event": "tts.done", "destination": "meeting"}
{"event": "tts.audio", "data": "base64-pcm-24khz...", "chunk_index": 0, "is_last": false, "duration_ms": 2500}
{"event": "tts.webpage_audio", "data": "base64-pcm-24khz..."}
{"event": "tts.error", "reason": "tts_unavailable"}
{"event": "tts.interrupted", "reason": "user_speaking", "played": ["..."], "not_played": ["..."]}
tts.started/done — bracket TTS generation with destination info.tts.interrupted — bot audio was stopped because a human started sustained speech (webpage modes, direct only). played lists sentences the participant heard fully; not_played lists sentences cut mid-way or never started. See "Interruption handling" above for the full debounce mechanic.tts.audio — raw 24kHz PCM chunks returned to agent (when destination: "agent").tts.webpage_audio — audio sent to webpage via tunnel (when destination: "webpage").{"type": "audio.chunk", "data": "base64-pcm-16khz...", "timestamp": "..."}
{"type": "screenshot.result", "data": "base64-jpeg...", "width": 1920, "height": 1080, "request_id": "req-1"}
{"type": "capture.started", "interval_ms": 1000}
{"type": "capture.frame", "data": "base64-jpeg...", "frame_number": 5}
{"type": "capture.stopped", "total_frames": 30}
{"type": "screenshare.started", "url": "https://..."}
{"type": "screenshare.stopped"}
{"type": "screenshare.error", "message": "Failed to load URL"}
audio.chunk requires audio_streaming: true in the call creation request (REST API only — not available as a CLI flag). This streams raw 16kHz PCM meeting audio to the agent. Most workflows don't need this — use transcript.final instead. See the Multilingual Note-Taker example for a use case.
{"type": "command.ack", "command": "meeting.send_chat", "request_id": "req-1"}
{"type": "command.error", "message": "Bot container not connected", "command": "meeting.send_chat"}
Send one JSON object per line.
bridge.py stdin compatibility: the Python bridge accepts both the
bridge shorthand form ({"command": "tts.speak", ...}) and the raw
API/WebSocket form ({"type": "tts.speak", ...}). For meeting actions, the
raw API names map to bridge commands:
| Raw API type | Bridge command |
| --- | --- |
| tts.speak | tts.speak |
| meeting.send_chat | send_chat |
| meeting.raise_hand | raise_hand |
| meeting.mic | mic |
| meeting.leave | leave |
| screenshot.take | screenshot |
Use either form consistently in a session. The bridge emits command.ack for
accepted stdin commands and command.error for unknown commands, so a missing
ack/error means the command line did not reach the running bridge process.
{"type": "inject.natural", "text": "Q3 revenue was $2.4M, up 15%", "priority": "normal"}
{"type": "inject.verbatim", "text": "The meeting will end in 5 minutes.", "priority": "high"}
{"type": "trigger.speak", "text": "Tell me about the financial results", "speaker": "Alice"}
{"type": "voice.contribute"}
{"type": "voice.context_update", "text": "Q3 Revenue: $2.4M, up 15% YoY. Enterprise: $1.6M..."}
trigger.speak — Conversational. Forces GetSun to respond to the text as if asked.
If interrupted, content is LOST — GetSun moves on (like a person being cut off).
Use for: answering direct questions, conversational replies.
inject.natural — High reliability. GetSun rephrases your text and speaks it at the
next natural pause. If interrupted, GetSun remembers and retries until fully spoken.
Keep inject text SHORT (1 sentence). Long inject text that gets interrupted causes
a retry loop — GetSun keeps coming back to finish, which feels robotic.
Use for: short announcements only ("I've got the results", "Task complete", "I found the issue").
Do NOT dump data into inject — put data in context_update, announce with inject.
inject.verbatim — Same as inject.natural but speaks exact text without rephrasing.
Same retry behavior — keep it short.
voice.contribute — GetSun reads the conversation and contributes something relevant
from its context at the next natural pause, without being addressed by name. Use when
the conversation topic matches data in the bot's context.
voice.context_update — Replaces GetSun's context scratchpad (4000 chars max).
This is where ALL data goes. Context is queryable — participants can ask follow-up
questions and GetSun answers from context instantly. Context is NOT conversation memory.
GetSun remembers the conversation separately.
Correct pattern for delivering results:
1. context_update → full data (deployment status, revenue numbers, etc.)
2. inject.natural → short announcement: "I've got the deployment status ready."
3. User asks follow-up → GetSun answers from context instantly
Anti-pattern (DO NOT do this):
inject.natural "The deployment is healthy. All 3 services running. Last deploy 2 hours
ago. No errors in 30 minutes. CPU 45%. Memory 62%..."
→ Long inject gets interrupted → GetSun retries → interrupted again → poor UX
Unified command — tts.generate with a destination field:
{"type": "tts.generate", "text": "Hello everyone!", "voice": "af_heart", "speed": 1.0, "destination": "meeting"} (speed 1.0 recommended)
{"type": "tts.generate", "text": "Welcome.", "voice": "bf_emma", "destination": "agent"}
{"type": "tts.generate", "text": "This plays on the webpage.", "destination": "webpage"}
Destinations:
"meeting" — resample 24→16kHz, rechunk 20ms, inject into call via FirstCall (meeting infrastructure) (bot speaks)."agent" — return raw 24kHz PCM chunks to you via tts.audio events (you decide what to do)."webpage" — send raw 24kHz to your webpage via tunnel (browser plays it).Shortcut: tts.speak auto-detects the correct destination based on mode:
{"type": "tts.speak", "text": "Hello everyone!", "voice": "af_heart", "speed": 1.0}
You do NOT need to specify a destination when using tts.speak — it is
inferred from the call mode. Use tts.generate with an explicit destination
only if you need to override the default routing.
tts.speak returns one tts.done (or tts.interrupted if the user
spoke over the bot) per call, regardless of text length. First audio reaches
the meeting in under 1 second. Send your response naturally in one tts.speak:
{"command": "tts.speak", "text": "Q3 revenue was 2.4 million. That's up 15 percent year over year. Enterprise was the main driver at 1.6 million."}
tts.done signals generation complete — audio may continue playing in the
meeting for a few seconds after. tts.interrupted can therefore arrive
after tts.done (user spoke during the tail of playback).
Conversational style: write tts.speak text the way you'd say it
aloud. TTS reads unknown chars by Unicode name (^ → "circumflex",
€ → "euro", ** → "asterisk asterisk"). No markdown, emojis, or
symbols. Spell out numbers and money ("2.4 million", not "$2.4M").
URLs/code/errors → send_chat.
{"type": "audio.inject", "data": "base64-pcm-16khz-16bit-mono..."}
{"type": "audio.clear"}
{"type": "meeting.send_chat", "message": "Notes shared in the doc."}
{"type": "meeting.raise_hand"}
{"type": "meeting.mic", "action": "on"}
{"type": "meeting.leave"}
{"type": "screenshot.take", "request_id": "req-1"}
{"type": "capture.start", "interval_ms": 1000}
{"type": "capture.stop"}
{"type": "screenshare.start", "url": "https://your-slides.com"}
{"type": "screenshare.start", "port": 3001}
{"type": "screenshare.swap", "url": "https://different-page.com"}
{"type": "screenshare.swap", "port": 3002}
{"type": "screenshare.stop"}
{"type": "voice.state_update", "state": "thinking"}
{"type": "events.replay"}
voice.state_update manually sets the avatar's voice state in direct mode (webpage modes only). Broadcast as voice.state event to all connected clients including the avatar template. States: listening, actively_listening, thinking, waiting_to_speak, speaking, interrupted, contextually_aware. Note: speaking and listening are set automatically around tts.speak — use this for custom states like thinking while processing.
events.replay requests buffered events for crash recovery. Returns last 200 events or 5 minutes. See Crash Recovery section.
The agent is an active meeting participant by default. Unless the user explicitly
asks for passive/silent/notetaker mode, the agent MUST:
greeting.prompt fires (first participant joins), greetthem via tts.speak. Example: "Hi [name], I'm [bot_name]. How can I help today?"
Skip the greeting ONLY if the user explicitly said not to introduce itself.
or directs speech at it, the agent MUST respond. This applies in BOTH one-on-one
and group meetings, in both collaborative and direct mode.
what's being discussed, it should contribute without waiting to be asked. If the
user explicitly asks the agent not to speak unless spoken to, respect that —
otherwise, contribute naturally.
3b. Keep responses short in normal conversation — 2-3 sentences max for greetings,
quick answers, and acknowledgments. Meetings are real-time — long monologues feel
robotic and block natural back-and-forth. The user can't easily interject during a
10-sentence TTS response. Use longer responses ONLY when the user explicitly asks
for detail ("explain this", "walk me through it", "give me a full summary"). For
long responses, break into chunks and pause between them so the user can interject.
Quick acknowledgments ("Got it", "Sure, one moment", "On it") should be one sentence.
meeting and sitting there silently while participants talk. If the agent is processing,
acknowledge first: tts.speak "Let me check that." In webpage modes
(webpage-av, webpage-av-screenshare) the avatar automatically shows "thinking"
the moment user.message arrives — the bridge handles this for you. The visual
clears when you respond (or after a short fallback if you don't), so just focus on
answering the user — no manual set_state thinking needed for the typical flow.
If the agent doesn't know what to say, it can still acknowledge: "I heard you, but I'm not sure how to help with that."
the user explicitly requests notetaking, silent observation, or passive mode.
The words "just take notes", "don't speak", "silent mode", "passive", or "notetaker"
are signals for passive mode. Everything else defaults to active participation.
In collaborative mode: The agent drives GetSun — use trigger.speak, inject.natural,
and voice.context_update to make the bot speak. GetSun handles the voice, but the agent
decides WHEN and WHAT to say.
In direct mode: The agent IS the voice — use tts.speak for every response. Silence
means the agent is not participating.
See also: THE CALL_LOOP algorithm (Pattern 5 → Method 1 for event-driven flow), Safety section (always send leave).
Agent joins → CALL_LOOP:
→ call.bot_ready: send voice.context_update with agent capabilities
(what you can do, "say 'let me check' instead of 'I can't'")
→ greeting.prompt received: inject.natural with greeting ("Hi [name], I'm [bot]. How can I help?")
→ Check events (every 5-10s)
→ transcript.final received:
→ If addressed: fetch data → context_update → trigger.speak
→ If task requested: GetSun says "let me check" → agent does work → context_update → inject.natural with results
→ If topic matches context: voice.contribute
→ If relevant discussion: proactively update context so GetSun can contribute
→ voice.text received (GetSun spoke):
→ If GetSun said "I can't" → context_update with capability + inject.natural correction
→ If GetSun deferred ("let me check") → agent must act now
→ If GetSun answered correctly → no action, context was good
→ No new events: sleep 5 → check again
→ call.ended: exit loop, get transcript → generate summary
GetSun handles the speaking — agent handles the thinking.
GetSun is always faster (<1s) — agent prepares context in advance.
The agent MUST keep checking events for the entire call duration.
Follow THE CALL_LOOP algorithm (see Pattern 5 → Method 1 below — event-driven, recommended).
Agent joins → CALL_LOOP:
→ greeting.prompt received: tts.speak "Hi [name]! I'm [bot]. How can I help you today?"
→ Check events (every 2-3s)
→ user.message received:
→ Simple question: tts.speak with answer
→ Complex question: tts.speak "Let me look into that"
→ Do ONE step → check call → next step → check call → tts.speak with answer
→ No new events: sleep 2 → check again
→ call.ended: exit loop
Agent IS the voice. Every second without checking = silence.
Always greet, always respond, always participate.
Follow THE CALL_LOOP algorithm (see Pattern 5 → Method 1 below — event-driven, recommended).
Agent joins with voice agent page on --port 3000 or --webpage-url
→ Page receives meeting audio as mic input
→ Page's AI processes and generates response
→ Page plays response audio → participants hear it
→ Agent monitors via transcript.final for logging/context updates
Use ONLY when the user explicitly asks for notetaking, silent observation, or passive mode.
Keywords: "just take notes", "don't speak", "silent mode", "passive", "notetaker".
If the user does not explicitly request silent mode, use Pattern 1, 2, or 5 instead.
Agent joins with --transcription
→ Collects all transcript.final events
→ Never speaks
→ After call.ended: process transcript, generate action items
Talk to the user via voice in a meeting. The agent framework (Claude Code,
Agent SDK, Cursor, Codex, OpenClaw, Windsurf, Gemini CLI, Junie) IS the
intelligence — no separate LLM needed. Transcripts arrive as input, TTS
responses go back as output. Same session, same context.
Use when the user shares a meeting link and wants to discuss via voice,
pair-program, brainstorm, or have the agent participate in a call as itself.
Three bridge scripts available:
| Script | Mode | Visual | Screenshare | Use case |
|--------|------|--------|-------------|----------|
| bridge.py | audio | No | No | Voice-only conversation (simplest, recommended for coding agents) |
| bridge-visual.py | webpage-av-screenshare | Avatar | Yes | Presentations, sharing content, visual presence |
| join.py | any | any | any | Full control, all raw events, custom agents |
bridge.py (recommended for voice conversation):
python scripts/python/bridge.py "https://meet.google.com/abc" --name "Claude" --voice af_heart
bridge-visual.py (avatar + screenshare):
# Built-in avatar (no local server needed)
python scripts/python/bridge-visual.py "https://meet.google.com/abc" --name "Claude"
# With local screenshare (agent runs local server on port 3001)
python scripts/python/bridge-visual.py "https://meet.google.com/abc" --screenshare-port 3001
# With public URLs for both avatar and screenshare
python scripts/python/bridge-visual.py "https://meet.google.com/abc" \
--webpage-url "https://your-site.com/avatar" \
--screenshare-url "https://your-site.com/slides"
bridge-visual.py extends bridge.py with:
{"command": "screenshare.start", "url": "https://..."}{"command": "screenshare.start", "port": 3001} (auto-tunneled){"command": "screenshare.swap", "port": 3002} or with a url (preferred over manual stop+start when changing what's shared){"command": "screenshare.stop"}screenshare.started, screenshare.stopped, screenshare.errorScreenshare lifecycle — end-to-end flow:
The bot joins with avatar only. Screenshare activates on demand:
1. Agent starts bridge-visual.py with --template avatar
→ Bot joins meeting with animated orb/avatar (camera feed)
→ Screenshare is inactive — participants see only the avatar
2. Conversation happens (voice, transcripts, etc.)
3. User: "Can you show me the data?"
Agent: {"command": "tts.speak", "text": "Sure, let me share my screen."}
Agent: {"command": "screenshare.start", "url": "https://my-dashboard.com/slides"}
→ Receives: {"event": "screenshare.started", "url": "..."}
→ Participants now see avatar (camera) + slides (screenshare)
4. Agent updates the screenshare content:
Agent writes: echo '{"slide": 1}' > /tmp/screenshare/state.json
Agent: {"command": "tts.speak", "text": "As you can see on slide 2..."}
5. User: "OK thanks, that's enough."
Agent: {"command": "screenshare.stop"}
→ Receives: {"event": "screenshare.stopped"}
→ Back to avatar only
6. Conversation continues without screenshare.
For local content (e.g., agent-generated HTML on localhost):
Agent starts a local HTTP server on port 3001 serving slides/charts
Agent: {"command": "screenshare.start", "port": 3001}
→ Bridge automatically creates a tunnel to localhost:3001
→ FirstCall loads it via the tunnel URL
Key points:
url for public content, port for local content (auto-tunneled)webpage-av mode if you never need screenshareSharing a live webpage (bridge-visual.py / bridge-visual.js):
The agent can share a webpage from its localhost that meeting participants open
in their own browser — fully interactive (clickable, scrollable, any viewport).
This is different from screenshare, which renders in a headless browser inside the meeting.
| | Screenshare | Webpage |
|---|---|---|
| Rendered in | FirstCall's headless browser | Participant's own browser |
| Interaction | No clicks/scroll | Full interaction |
| Viewport | 1280x720 fixed | Any (participant's browser) |
| Visible to | All meeting participants (in-meeting) | Anyone with the URL |
| Use case | Presentations, slides | Dashboards, docs, forms, code |
Commands:
{"command": "webpage.open", "port": 3002}
{"command": "webpage.close"}
Events:
{"event": "webpage.opened", "url": "https://xyz.conn.agentcall.dev/k/{accessKey}/webpage/"}
{"event": "webpage.closed"}
{"event": "webpage.error", "message": "..."}
Workflow:
1. Agent starts a local HTTP server:
python -m http.server 3002 --directory /tmp/my-report/
2. Agent opens the webpage tunnel:
{"command": "webpage.open", "port": 3002}
→ Receives: {"event": "webpage.opened", "url": "https://xyz.conn.agentcall.dev/k/.../webpage/"}
3. Agent shares the URL in meeting chat:
{"command": "send_chat", "message": "Here's the report: https://xyz.conn.agentcall.dev/k/.../webpage/"}
4. Participants click the link → see the agent's page in their browser
5. Agent updates files on disk → participants refresh to see changes
(or use the polling pattern from screenshare for auto-updates)
6. When done:
{"command": "webpage.close"}
Use cases:
Requirements: bridge-visual.py or bridge-visual.js (needs an active tunnel).
The webpage tunnel lives as long as the call — it closes automatically when the call ends.
Audio routing is automatic (bridge-visual.py): When you send tts.speak,
the audio is automatically routed to the avatar webpage — NOT directly to
FirstCall. The avatar template plays the audio via Web Audio API, FirstCall
captures the browser's audio output, and meeting participants hear it. You
do NOT need to specify a destination or use tts.generate — just send
tts.speak with your text and the routing is handled based on the mode.
In audio mode (bridge.py), audio goes directly to FirstCall. In webpage
modes (bridge-visual.py), audio goes to the webpage. Same command, automatic routing.
Voice state updates are automatic (bridge-visual.py): The avatar shows
"thinking" (purple glow) the moment user.message arrives, "speaking"
(green glow) when TTS is playing, and returns to "listening" (subtle pulse)
when done — no manual state management needed for the common flow. The
bridge clears "thinking" the instant you respond (or after a short fallback
if you stay silent).
For other states (e.g., flashing interrupted when cancelling), use
set_state — your explicit choice always overrides the auto behavior:
{"command": "set_state", "state": "interrupted"}
{"command": "set_state", "state": "listening"}
Available states: listening, actively_listening, thinking,
waiting_to_speak, speaking, interrupted, contextually_aware.
Showing work-in-progress to participants (bridge-visual.py):
When the agent is doing extended work (research, multi-step processing,
async tool calls, anything that takes more than a few seconds), the
participants need to see what the bot is working on. Use tasks.set to
post a list of short task titles below the avatar status. The list is a
separate UI layer from the voice state — it doesn't replace
"speaking"/"thinking"/etc.; it shows ALONGSIDE them.
You MUST keep the list current. Call tasks.set immediately when you start
a new task, immediately when you finish one, and immediately whenever you
switch focus. A stale "Working on X" while you've moved on to Y is worse than
showing no list at all — participants lose trust in the indicator.
{"command": "tasks.set", "tasks": ["Researching pricing", "Pulling Q3 numbers"]}
Each tasks.set is an atomic full-list replacement — send the complete
list every time, not deltas. To mark a task done, send a new list
without it; the avatar fades that line out gracefully (~400ms).
{"command": "tasks.set", "tasks": ["Pulling Q3 numbers"]} // first task done
{"command": "tasks.set", "tasks": []} // all done; clear
Limits: max 3 visible tasks at once, max 30 characters per task title
(longer items are silently truncated by the bridge). Just titles — no
status, no icons, no detail. Keep them short and active-tense:
"Researching pricing" not "Research the pricing data from competitors".
When to use:
When NOT to use:
state is enough for normal back-and-forth)
"Calling tool")
The list automatically clears when the call ends. Independent of all
state machines — sending tasks.set doesn't affect auto-thinking,
voice state, or anything else.
bridge-visual additional commands:
| Command | Fields | What it does |
|---------|--------|-------------|
| screenshare.start | url OR port | Share a URL or local port (auto-tunneled) into the meeting as screenshare. For local-port shares the bridge appends a cache-buster (?_acv=<ms>) to the tunnel URL so FirstCall's headless browser reloads cleanly between swaps; external URLs pass through unchanged (avoids breaking signed URLs like S3 pre-signed, Vimeo private, secure embeds). If port is unreachable on localhost, emits screenshare.error instead of showing a white page. |
Take pattern-ai-labs/join-meeting from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip, npm.
Without those the skill loads but fails at the first command.