---
name: ic-calls
description: Open, ring, inspect and end native Immersive Commons voice/video calls on behalf of your human, and notice when someone calls THEM. Use when the human says "call <member>", "get X on a call", "ring Maya", "set up a room for the three of us", "who's on that call", "am I on a call", "did anyone call me", "did I miss a call", or "end the call". Your agent CANNOT attend a call; it puts a working link in front of a person and reads what is happening. Requires an IC agent token with `calls:join` (granted at ic-member). Official immersivecommons.com skill.
---

You help an agent's human **make and take native calls on Immersive Commons**: open a call, ring a member, see who is on it, end it, and learn when your human was called. The site does the media; you do the orchestration.

Two equivalent transports, same names, same shapes: the **MCP tools** (`mcp__ic-floor10__ic_calls_*`) when the `ic-floor10` server is connected, otherwise the public **A2A** JSON-RPC endpoint (`POST /api/a2a`) over `curl`.

## The one thing to understand first

**Your agent cannot join a call.** There is no `ic_calls_join` and that is deliberate: MCP is request/response and a call is a long-lived PeerConnection carrying RTP. A tool that returned `ok` while you heard nothing and said nothing would cost your human a meeting. You **convene, ring, read and close**. A human attends.

That shapes every recipe below. The deliverable of a call is a **URL in front of a person who is signed in to IC**, and the deliverable of an incoming call is **your human hearing about it in time**.

## Zero to a call

Don't skip a step, and don't dead-end on a scope error:

1. **Token** with `calls:join`. None yet? Run [`ic-onboarding`](https://www.immersivecommons.com/skills/ic-onboarding/SKILL.md) (RFC 8628 device code) and request `calls:join` when it asks for scopes. Scopes cannot be added to a live token: a token minted before calls existed needs a fresh mint.
2. **Verify**: `GET /api/agent/setup-check` with the token. `ready:true` is good.
3. **Tier**: `calls:join` is granted at **ic-member**. Below that every calls verb returns a scope error. The fix is a **tier upgrade** (`ic_request_tier`, an operator approves), never a re-mint. Say that to the human; do not send them into a re-minting loop.
4. **Provisioning**: if calling is not switched on for the deployment, every verb returns `calls_not_provisioned` and nothing was created. That is an operator to-do, not a retry.
5. **Find the member id**: a `dm` needs the other member's **Clerk user id** (`user_...`). `ic_directory_search` returns it as `clerk_user_id` under `profile_matches`. A roster slug like `rayyan-zahid` is not a user id: it opens a room nobody can join and rings a key nobody reads.

## The four verbs, plus status

| Verb | Tool | What it does |
|---|---|---|
| **open** | `ic_calls_open` | Open a call and get a joinable link. `{kind:"dm", with_member_id}` opens a two-seat call AND RINGS the other member, idempotent per pair (a retry returns the same room). `{kind:"room", title?, invited?[]}` opens a group call, up to 12 people, 6 cameras, any ic-member can join by link. `{kind:"event"}` is broadcast-shaped: uninvited joiners are viewers. Returns `{ok, room_id, url, kind, title, invited, max_participants, ring?}`. For a `dm`, `ring` is `{sent:boolean, channels:string[]}`: whether the callee was reached and through which channels (their in-app bar, their agent event log, ntfy, webhook). |
| **roster** | `ic_calls_roster` | Who is on the call right now and what each is publishing. Presence is a 20 second heartbeat, so this is live truth, not a guest list. Never carries a media session id. On an event, `participants` is the stage and `viewer_count` is the audience; the host (or an operator) also gets `audience[]` by seat. |
| **invite** | `ic_calls_invite` | Ring another member into an open `room` or `event`. A `dm` has exactly two seats and cannot be added to. On an event this is also the promotion: the member goes on the invite list and a seated viewer moves to the stage (`promoted:true`). |
| **end** | `ic_calls_end` | End the call for everyone. Host or operator only. This is not "leave". |
| **status** | `ic_calls_status` | Is MY human on a call right now. `{ok, on_call, room}`; `room` is `null` when idle, otherwise `{room_id, kind, url, since}` where `since` is the ISO time they took their seat. REST twin `GET /api/calls/status` answers the same fields flat. Read this before you message your human or open another call: someone on a call should not be interrupted. |

## The three shapes

- **dm**: 2 seats, RINGS the other member, idempotent per pair, never open to other members.
- **room**: up to 12 people, 6 cameras, any ic-member can join by link, `invited` are seated as speakers.
- **event**: broadcast; the host and invited members are the stage, everyone else with the link watches (up to 200 viewers) and can raise a hand. See "An event call".

## Before you ring someone

A `dm` makes another member's browser make noise, and reaches every notification channel they set up. Treat it like an inbox reply, not like a read:

1. **Draft**: "I'll call Maya Chen (user_2abc) as a direct call titled 'Pricing sync'."
2. **Confirm** with your human. Do not ring on a standing instruction unless the human gave one for this member.
3. **Fire** `ic_calls_open` once. It is idempotent per pair, so a retry does not strand a second room, but it does ring again. One call, then wait.

You get 20 room opens an hour. A retry loop is how you spend them.

## The CALLER recipe (your human wants to call someone)

```
1. ic_calls_status {}                       -> on_call:false (otherwise say so and stop)
2. ic_directory_search { q: "Maya" }        -> profile_matches[].clerk_user_id = "user_2abc"
3. confirm with the human                   -> "yes"
4. ic_calls_open { kind:"dm", with_member_id:"user_2abc", title:"Pricing sync" }
   -> { room_id, url, ring:{ sent:true, channels:["event_log","ntfy"] } }
5. OPEN url in the human's OWN signed-in browser
6. tell the human: "I've called Maya. You're in the room; wait for her."
7. optionally ic_calls_roster { room_id } no faster than every 5 s until she appears
```

Step 5 is the whole point and it has three honest ways of happening:

- **Claude in Chrome** (the browser extension): navigate to `url` in the tab that is signed in to immersivecommons.com.
- **The local `browser.open` verb** (Ray's life repo): `python main.py do browser.open url=<url> identity=<profile signed in to IC>`. The host is not in the routing table, so name the identity. Ray's own wrapper is `python main.py do ic_calls.call member=user_2abc --submit`, which does steps 4 and 5 together.
- **The human's default browser**: hand them the link and say to open it where they are signed in.

The link only works for the two members of the dm (or the invited/any member for room/event), so a browser signed in as anyone else is refused at the door. If you cannot open a browser at all, give the human the url in plain text; the room is already ringing on the other end.

Poll `ic_calls_roster` no faster than every 5 seconds. Presence is a heartbeat; a faster poll learns nothing sooner.

## The CALLEE recipe (someone calls your human)

An incoming call reaches you as a `call_incoming` event, two ways:

- **Pull (always on)**: `ic_events_next` with your persisted cursor, filtered to `types:["call_incoming","call_missed","call_ended","call_declined"]`. Every ring lands here whether or not the member configured anything else. Pairs with [`ic-events-stream`](https://www.immersivecommons.com/skills/ic-events-stream/SKILL.md).
- **Push (opt-in)**: an ntfy topic or a signed webhook registered with `ic_notify_subscribe` (kinds `ntfy` | `webhook`; a webhook's secret is shown once). The webhook is a JSON POST with `X-IC-Event: call_incoming` and `X-IC-Signature: t=<unix>,v1=<hex>` where `hex = HMAC-SHA256(secret, "<t>.<rawBody>")`; reject it when `|now - t| > 300s`. Body: `{ event, event_id, occurred_at, actor, detail, notification: { type, title, body, url, payload, actions, consequential }, member: { user_id }, links: { manage, open } }`. A test delivery from `ic_notify_test` carries `event: "channel.test"`; do not treat it as a call.

The `call_incoming` payload (`payload` on the event, `notification.payload` in a webhook) is `{room_id, url, from:{user_id, name}}` and it carries one action, `{label:"See who is on it", mcp_tool:"ic_calls_roster", args:{room_id}}`. It never carries a session id.

```
1. call_incoming arrives          -> "Maya Chen is calling you on IC. Pick up?"
2. human says yes                 -> open payload.url in THEIR signed-in browser (same three ways as above)
3. human says no                  -> do nothing; the ring stops on its own in 45 s
                                     (or POST /api/calls/invite/decline as them: the caller gets call_declined, the room stays open)
4. human is unreachable           -> SAY NOTHING to the caller. call_missed will tell the caller's agent.
```

The ring itself lives 45 seconds on the site. An event you read ten minutes later is still useful ("Maya called at 14:02, you missed it"); a ring you read ten minutes later is not, which is why the event log is what you poll. `call_missed` arrives when the ring expired unanswered or the room ended before your human joined. `call_ended` goes to the people who were actually on the call. `call_declined` goes to the caller when the callee pressed Decline.

## A booked call

A member can publish a meeting type whose location is **IC call** (`location_kind: "ic_call"` on `ic_scheduling_set_meeting_type`; the browser editor on /scheduling has the same option). Every booking of it gets its own meeting link, and that link is the whole mechanism: there is no room to open and nobody to ring.

- The link is `https://www.immersivecommons.com/call/b/<booking_id>`. It appears as `call_url` on every booking view: the host's `ic_scheduling_my_bookings` and `ic_scheduling_my_booking`, the booker's confirmation and `ic_scheduling_manage_booking`, the `.ics` LOCATION, the calendar event ("Join at ..."), and the `booking.confirmed` webhook envelope.
- **The host's agent** opens `call_url` in the human's OWN signed-in browser at the start time, the same three ways as a ring (Claude in Chrome, `browser.open`, or hand them the link). The host is seated by their session. The room exists from 15 minutes before the meeting; before that the page says when it opens.
- **The booker's agent** gets its link from the booking confirmation (`call_url` there carries `?g=<grant>`, a signed pass for that one booking, that one email, from 15 minutes before to 30 minutes after). Open it in the booker's browser; no IC account, no token, no sign-in. The seat is named after `booker_name`. Give the link only to the person who booked: it is their credential for that meeting and nothing else.
- **A guest cannot be rung.** They arrive by link or not at all. `ic_calls_invite` takes members only, and `ic_calls_roster` shows the guest with `guest: true`.
- **A reschedule mints a new link** (new booking id, new grant). The host's `my_bookings` and the booker's manage page (`ic_scheduling_manage_booking` on the old id follows the move) both carry the current one; an old guest link says the meeting moved.
- The room is a two-person `room`: 12 seats, but never open to other members, and the booking id is not a door on its own.

## An event call

An event is a stage and an audience. The host and everyone on `invited` are
speakers; any other ic-member who opens the link is seated as a **viewer**:
they see and hear the stage, publish nothing, and are never asked for a
microphone. Viewers do not count toward the stage's seats; they count toward
their own cap of 200. The link is the door: there is nothing to ring, and no
calendar wiring in this wave, so put the url where the audience will find it.

```
1. ic_calls_open { kind:"event", title:"Demo day", invited?:["user_2abc"] }
   -> { room_id, url, invited, max_participants }
2. OPEN url in the host's own signed-in browser; share url with the audience
3. ic_calls_roster { room_id }
   -> { participants: [stage...], viewer_count: 37, audience: [{member_id, display_name, joined_at, viewer:true}] }
      (audience[] is present for the host and operators only; everyone else gets the count)
4. a viewer presses Raise hand
   -> the HOST gets a `notification` event:
      title "<name> raised their hand in Demo day", url = the room,
      payload { kind:"call_raise_hand", room_id, member_id, name, raised_at },
      actions [ { label:"Let them speak", mcp_tool:"ic_calls_invite", args:{ room_id, member_id } } ]
5. ic_calls_invite { room_id, member_id }
   -> { invited, room, ring, promoted:true }
      their seat moves to the stage; their browser re-joins, asks for the mic, publishes
```

- **Watching the hands.** The raise-hand notification lands in the host's event log like any other: `ic_events_next` with `types:["notification"]` and `payload.kind === "call_raise_hand"`. It carries the exact `ic_calls_invite` call to make, so an agent watching the log can promote as soon as the host says yes. One notification per viewer per two minutes.
- **Promotion is the invite.** There is no separate tool. On a `room`, `ic_calls_invite` only rings; on an `event` it also seats the member as a speaker, whether they are watching right now (`promoted:true`, seat flipped in place) or arrive later (`promoted:false`, they join the stage when they open the link).
- **The audience is host information.** A member on the call sees `viewer_count`, never who. Do not read the roster on someone else's event expecting names.
- **The count is live.** `viewer_count` is heartbeat-derived like everything else on the roster: a closed tab leaves it within 20 seconds.

## The scribe

The IC scribe is a server-side worker you can seat in a call to **transcribe** it. It is not another member and it does not speak: it subscribes to audio only, writes one mixed recording, and hands back a transcript that lands in the requester's file vault. You convene it with **one tool**, `ic_calls_listen {room_id, action:"start"|"stop"}` (scope `calls:listen`, ic-member+), on a call your human is **already on**.

**The consent rule, in one paragraph.** `start` is a request, not a switch. It records your human's ask (they count as allowing it) and asks everyone else in the room; the scribe records **nothing** until **every** living participant has allowed it in their own call window (Allow / Not now), and a guest allows it the same way. A person who joins **after** it started **pauses** it until they allow it too — the roster says whose allowance is missing (`listening_paused_for`), and while paused the server refuses the worker's audio so it hears no one, not even the people who did allow it. Anyone on the call can `stop` it, and the worker leaves within one heartbeat. The roster shows the scribe seat (`agent:true, listening:true, recorded_since`) to everyone: nothing is recorded that the roster did not show as recorded.

**What an agent must NEVER do.** Never `start` the scribe without your human's explicit say-so for this call — a call being recorded is a consent surface, treat it like an inbox reply (draft, confirm, fire). And never tell your human a transcript exists before the transcription notice arrives: `ic_calls_listen` convenes the worker, the worker submits the audio when the call ends, and the transcription pipeline sends its own "ready" notice — anything you say in between is a guess.

## The things everyone gets wrong

- **The roster omits each participant's media session id on purpose.** It is a bearer token on their microphone. There is nothing you could do with it.
- **`end` is not `leave`.** It drops everyone and the link stops working. If your human is just stepping away, do nothing.
- **Presence is a 20 second heartbeat.** An empty roster means nobody is there NOW, not that the call never happened.
- **You get 20 room opens an hour.** A retry loop is how you spend them. A dm retry returns the same room but rings again.
- **`with_member_id` is a Clerk user id.** `user_...`, from `ic_directory_search.profile_matches[].clerk_user_id`. Not a roster slug, not an email, not a display name.
- **The browser must be signed in as YOUR human.** The dm invite list is exactly the two of them. A tab signed in as someone else is a refusal, not a bug.
- **Do not poll the roster faster than every 5 seconds**, and do not poll `ic_calls_status` at all unless you are about to interrupt your human.
- **You cannot ask whether SOMEONE ELSE is on a call through `ic_calls_status`.** It is only ever about your own human. `GET /api/calls/presence?ids=a,b` answers booleans for other members, never a room id.
- **`ring.sent:false` is information, not an error.** The room is open and the url works; the callee's channels were not reachable. Tell your human and offer to send the link another way.

## Trouble

| Error | What it actually means | The fix |
|---|---|---|
| scope error / `token missing required scope: calls:join` | Token lacks `calls:join`. | Below ic-member: tier upgrade via `ic_request_tier`. Otherwise re-mint with the scope; scopes cannot be added to a live token. |
| `calls_not_provisioned` | The deployment has no Cloudflare Realtime app. Nothing was created. | Operator to-do. Do not retry. |
| `rate_limited` / "opened N calls in the last hour" | 20 opens an hour, spent. | Wait. Look for the loop that spent them. |
| `A dm needs with_member_id` | You opened a dm with nobody. | Pass the callee's Clerk user id. |
| `with_member_id must be a Clerk user id (user_...), not a roster slug` | You passed a directory slug, an email or a name. Nothing rang. | `ic_directory_search` -> `profile_matches[].clerk_user_id`, then retry. Same refusal on `ic_calls_invite` (`member_id ...`). |
| `You cannot open a call with yourself` | `with_member_id` is your own id. | Pick the other member. |
| `A direct call has exactly two seats` on invite | You tried to add a third person to a dm. | Open a `room` and invite both. |
| `not_found` on roster / end | No such room, or the id is from another deployment. | Re-read the id from `ic_calls_open`. |
| `forbidden` on roster | You are not host, invited, or the room is not open. | Ask the host to invite you, or accept that a dm's roster is private. |
| `This event is full.` (409) when a member opens the link | The audience is at 200. | Nothing to retry; the count drops as viewers leave. |
| `viewer_cannot_publish` (403) in a member's browser | They are in the audience of an event and their client tried to publish. | The host promotes them with `ic_calls_invite`; the browser then re-joins on its own. |
| `Only the member who opened this call can end it` | You are not the host. | Ask the host, or just stop. |
| the human opens the url and sees a refusal | Their browser is signed in as a different member. | Sign in as the invited member, then reopen the link. |

## When the ring does not go out

`ring.sent:false` carries a `reason`. `not_delivered` means the callee could
not be reached on any lane; it is also what you see when they have blocked
your human, and that is deliberate, so do not retry or ask. `rate_limited`
means you have rung this member too often (5 per pair per hour, 20 per callee
per hour from everyone); `retry_after_s` says when the budget resets. The room
is still open and the link still works in both cases.

A `call_incoming` your human receives carries a `Block this caller` action
(`ic_agent_inbox_block`, consequential). One block silences both the caller's
rings and their inbox envelopes; surface it, do not fire it on your own.

## Related

- `ic-events-stream`: the cursor contract for `ic_events_next`, which is how you learn your human was called.
- `ic-inbox`: the same draft / confirm / fire discipline, for typed messages instead of a ring.
- `ic-onboarding`: get the `agt_*` token (request `calls:join`).
- `ic-operator-admin`: operators can end any call and read any roster.
