call-sharing
$
npx mdskill add BuilderIO/agent-native/call-sharingEnable secure call distribution with password and expiry controls.
- Manages per-user and per-organization access grants for calls.
- Integrates with the framework sharing system and database resources.
- Determines visibility levels based on private, org, or public settings.
- Delivers encrypted playback requiring optional password entry for viewers.
SKILL.md
.github/skills/call-sharingView on GitHub ↗
---
name: call-sharing
description: >-
How Calls shares calls and snippets — composes with the framework sharing
skill and adds password, expiry, shareIncludesSummary, shareIncludesTranscript,
embed URLs, and view counting. Use when wiring the share dialog, building
embeds, adding a password, or debugging who can see a call.
---
# Call Sharing
## Rule
Call and snippet sharing uses the framework `sharing` system — not a custom share table. Calls and snippets are registered via:
```ts
// server/db/index.ts
registerShareableResource({ type: "call", table: calls, sharesTable: callShares, ... });
registerShareableResource({ type: "snippet", table: snippets, sharesTable: snippetShares, ... });
```
This wires up the auto-mounted `share-resource`, `unshare-resource`, `list-resource-shares`, and `set-resource-visibility` actions. They handle per-user grants, per-org grants, and the three visibility levels (`private` / `org` / `public`).
On top of the framework, Calls adds four things:
1. **Password** — an optional bcrypt'd string on the `calls` / `snippets` row. Non-owner viewers must enter it to play.
2. **`expiresAt`** — an optional ISO timestamp. After this time, non-owner access is denied.
3. **`shareIncludesSummary`** — boolean on `calls`. When false, the public share page hides the AI summary.
4. **`shareIncludesTranscript`** — boolean on `calls`. When false, the public share page hides the transcript.
These are **additive** — they never grant access the framework denies, only tighten it or hide sub-resources.
## When to use
Read this skill before:
- Wiring the Share dialog on a call or snippet page
- Adding password, expiry, or summary / transcript visibility toggles
- Building embed URLs (`?t=`, `?autoplay=`, `?hideControls=`)
- Debugging "why can't Alice see this call?"
- Touching `server/routes/api/public-call.get.ts` or `server/routes/api/public-snippet.get.ts`
## Data model touched
- **`calls.password`** (nullable) — bcrypt hash.
- **`calls.expires_at`** (nullable ISO string).
- **`calls.share_includes_summary`** (boolean, default true).
- **`calls.share_includes_transcript`** (boolean, default false).
- **`call_shares`** — framework-managed. Do not insert directly.
- **`calls.visibility`** — framework-managed column from `ownableColumns()`.
- **`call_viewers`** + **`call_events`** — view counting.
- Snippets mirror all of the above in `snippets` / `snippet_shares` / `snippet_viewers` (no per-field summary/transcript toggles — snippets are always a single moment).
## Dropping in the share UI
```tsx
import { ShareButton } from "@agent-native/core/client";
<ShareButton
resourceType="call"
resourceId={call.id}
resourceTitle={call.title}
>
{/* Calls-specific extras slot inside the dialog */}
<PasswordField callId={call.id} />
<ExpiryField callId={call.id} />
<SwitchField
label="Include AI summary on public page"
value={call.shareIncludesSummary}
onChange={(v) => update({ id: call.id, shareIncludesSummary: v })}
/>
<SwitchField
label="Include full transcript on public page"
value={call.shareIncludesTranscript}
onChange={(v) => update({ id: call.id, shareIncludesTranscript: v })}
/>
</ShareButton>
```
All four extras call `update-call` with the relevant fields.
## Access resolution
The player and `/api/public-call` route check access in this exact order:
```ts
async function canAccessCall(callId: string, requester: Session | null, providedPassword?: string) {
// 1. Framework check — owner, shared, or meets visibility.
const access = await resolveAccess("call", callId, requester);
if (!access.allowed) return false;
const call = await getCallOrThrow(callId);
// 2. Expiry — non-owner only.
if (call.expiresAt && requester?.email !== call.ownerEmail) {
if (new Date(call.expiresAt) < new Date()) return false;
}
// 3. Password — non-owner only.
if (call.password && requester?.email !== call.ownerEmail) {
if (!providedPassword) return false;
if (!(await bcrypt.compare(providedPassword, call.password))) return false;
}
return true;
}
```
Framework first, Calls additions second. Don't invert this — the framework owns the "is this row visible at all" question. Same logic applies to snippets against `call_shares` → `snippet_shares` and the snippet's own `password` / `expiresAt`.
## Public URLs
| URL | What it renders |
| -------------------------------- | ----------------------------------------------------------------------- |
| `/share/<callId>` | Public call page — player + (optional) summary + (optional) transcript. |
| `/embed/<callId>` | Iframe-embeddable player (no chrome). |
| `/share-snippet/<snippetId>` | Public snippet page — player clamped to `[startMs, endMs]`. |
| `/embed-snippet/<snippetId>` | Iframe-embeddable snippet player. |
All four require `visibility=public` on the underlying row or an explicit per-user share grant.
## Embed URL params
| Param | Meaning |
| ------------------ | -------------------------------------------------------- |
| `?t=80` | Start playback at 80 seconds |
| `?autoplay=1` | Autoplay (muted — browsers block unmuted autoplay) |
| `?hideControls=1` | Hide the player chrome |
| `?loop=1` | Loop playback |
| `?showTranscript=1` | Show transcript below player (call embed only) |
| `?showSummary=1` | Show summary below player (call embed only) |
Snippet embeds do not honor `showTranscript` / `showSummary` — they're a single moment.
## View counting
A view counts when **any** of these is true:
- The viewer has watched **≥ 5 seconds** of total real playback time
- The viewer has hit **≥ 75% completion**
- The viewer has scrubbed to the very end
The canonical predicate is `shouldCountView(totalWatchMs, completedPct, scrubbedToEnd)` — live in `server/lib/calls.ts`. Always go through it.
```ts
if (!viewer.countedView && shouldCountView(viewer.totalWatchMs, viewer.completedPct, scrubbedToEnd)) {
await db.update(schema.callViewers)
.set({ countedView: true })
.where(eq(schema.callViewers.id, viewer.id));
}
```
Events feeding this live in `call_events`. The `POST /api/view-events` route receives `view-start`, `watch-progress` (every 5s), `seek`, `pause`, `resume`, `reaction`. Aggregate into `call_viewers` on write to keep `get-call-insights` fast.
Snippet view counting uses `snippet_viewers` — see the `snippets` skill. Call views are not incremented by snippet playback.
## Anonymous viewers
`call_viewers.viewer_email` is **nullable** — anonymous viewers (public link, no account) still get a row keyed by a cookie id carried in `viewerName`. Never require login to watch a public call; require it only when the share grant is user-scoped.
## Rules
- **Never** write to `call_shares` / `snippet_shares` directly. Always go through `share-resource` / `unshare-resource`.
- **Never** store a plaintext password. Use bcrypt on write; bcrypt-compare on read.
- **Never** bypass the access check on `/api/call-media/:callId` or `/api/snippet-media/:snippetId`. Streaming routes are the #1 data-leak vector.
- **Password + expiry + summary/transcript flags are additions** — they never grant access the framework denies. Framework `accessFilter` runs first.
- **Embed routes are anonymous by default** — don't require auth, but still go through `canAccessCall`.
- **`shareIncludesSummary` / `shareIncludesTranscript` are render-time flags** — the API doesn't strip the data, the public page just hides the panel. Do not use these to gate the `/api/public-call` response shape, or the agent won't be able to answer "what's in this call?" for the owner.
## Related skills
- `sharing` — framework-level primitive Calls composes with. Read this first.
- `snippets` — snippet-specific sharing rules and the pointer-only playback model.
- `security` — password handling, token storage, anonymous viewer cookies.
- `storing-data` — why `password` / `expiresAt` / `shareIncludes*` live on the `calls` row instead of a parallel table.
More from BuilderIO/agent-native