- name
- video-sharing
- description
- How Clips shares recordings — composes with the framework sharing skill and adds password, expiry, embed URLs, view-counting, and per-viewer "Viewed by" records. Use when wiring the share dialog, building embed links, adding a password, showing who viewed a clip and when, debugging who can see a recording, or when an agent receives a Clips link and needs its transcript or video frames.
# Video Sharing
## Rule
Recording sharing uses the framework `sharing` system — not a custom share table. Recordings are registered via `registerShareableResource({ type: "recording", ... })` in `server/db/index.ts`. The `share-resource`, `unshare-resource`, `list-resource-shares`, and `set-resource-visibility` actions are auto-mounted and handle per-user grants, per-org grants, and the three visibility levels (`private` / `org` / `public`).
Unlike the framework-wide private default, normal Clips recordings and uploaded
videos default to **public** so their copied share links work immediately.
Embedded bug-report recordings are the exception and default to organization
visibility. Callers can still explicitly create a private or organization-only
recording, and owners/admins can change visibility from the Share dialog.
Organization admins can use `set-organization-branding` with
`defaultVisibility=public|org|private` to choose the visibility applied when new
recordings omit an explicit visibility. The default remains `public`, and an
explicit visibility always wins — which is why bug-report recordings still land
on `org`.
Clips **adds two things** on top of the framework system:
1. **Password** — an optional bcrypt'd string on the `recordings` row. When set, all non-owner viewers must enter it to play the recording.
2. **`expiresAt`** — an optional ISO timestamp on the `recordings` row. After this time, all non-owner access is denied (even to principals with explicit grants).
These are **additive** — they never grant access the framework denies, only tighten it.
## When to use
Read this skill before:
- Wiring the Share dialog on a recording page
- Adding a password or expiry UI
- Building embed URLs (`?t=`, `?autoplay=`, `?hideControls=`)
- Building AI-readable public clip URLs or transcript/frame endpoints
- Debugging "why can't Alice see this video?"
- Touching `server/routes/video/[id].ts` or `server/routes/share/[id].ts`
## Data model touched
- **`recordings.password`** (nullable text) — bcrypt hash.
- **`recordings.expires_at`** (nullable ISO string).
- **`recording_shares`** — framework-managed. Do not insert directly — use `share-resource`.
- **`recordings.visibility`** — framework-managed column from `ownableColumns()`.
- **`recording_viewers`** + **`recording_events`** — view counting.
- **`recording_views`** — append-only per-view log (who viewed, when) backing the owner-facing "Viewed by" popover. See "View counting" below.
- **`recording_agent_views`** — outside agents reading a clip through its public agent APIs, counted separately from humans. See "Agent views" below.
## Dropping in the share UI
Clips' `app/components/player/share-dialog.tsx` is a **thin wrapper around the framework `ShareDialog`** from `@agent-native/core/client`. The framework component handles per-user / per-org grants, visibility, and tabbed copy-link / embed UI — Clips just composes it with recording-specific extras.
```tsx
import { ShareDialog } from "@agent-native/core/client";
<ShareDialog
resourceType="recording"
resourceId={recording.id}
resourceTitle={recording.title}
shareUrl={`${origin}/share/${recording.id}`}
embedUrl={`${origin}/embed/${recording.id}`}
linkTabExtras={
<>
{/* Password + expiry render in the Link tab, below the share URL. */}
<PasswordField recordingId={recording.id} />
<ExpiryField recordingId={recording.id} />
</>
}
embedTabContent={<EmbedSnippetAndOptions recordingId={recording.id} />}
/>;
```
- `shareUrl` / `embedUrl` — the copy-link and embed URLs the framework renders in its tabs.
- `linkTabExtras` — Clips-specific controls (password, expiry) shown beneath the link.
- `embedTabContent` — full replacement for the Embed tab body (embed code, params like `?t=`, `?autoplay=`).
The password and expiry fields call `update-recording --password=...` / `--expiresAt=...`. Keep Clips' share-dialog wrapper minimal — any new generic sharing feature belongs in the framework component, not here.
## Shared with me
Use `list-recordings --view=shared` to list recordings the current user can
access but does not own. The filter composes with `accessFilter`, so it includes
direct user/org grants and organization-visible recordings while excluding
public-link-only clips. The UI exposes the same collection at `/shared`; use
`navigate --view=shared` to open it. `view-screen` returns the recordings
currently visible in that collection.
## Discovery boundary for public clips
Public recordings are unlisted-by-link for agent purposes: an agent may discover
only recordings the current user owns or has already viewed. Do not use
`list-recordings` or `search-recordings` to discover another user's public clips,
to answer a time/date question about the clip already in context, or to recover
from a failed direct lookup. If the user supplies another clip's share URL or id,
use that explicit reference; otherwise stop and report the lookup failure.
## Access resolution
The player and `/api/video/:id` route check access in this exact order:
```ts
async function canAccess(
recordingId: string,
requester: Session | null,
providedPassword?: string,
) {
// 1. Framework check — owner, shared, or meets visibility.
const access = await resolveAccess("recording", recordingId, requester);
if (!access.allowed) return false;
const rec = await getRecordingOrThrow(recordingId);
// 2. Expiry — non-owner only.
if (rec.expiresAt && requester?.email !== rec.ownerEmail) {
if (new Date(rec.expiresAt) < new Date()) return false;
}
// 3. Password — non-owner only.
if (rec.password && requester?.email !== rec.ownerEmail) {
if (!providedPassword) return false;
if (!(await bcrypt.compare(providedPassword, rec.password))) return false;
}
return true;
}
```
Framework first, Clips additions second. Don't invert this — the framework owns the "is this row visible at all" question.
## Embed URLs
Embeds live at `/embed/:shareId` (a share-scoped anonymous route). Supported query 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 |
Build embed URLs via the `build-embed-url` action:
```ts
const { url } = await callAction("build-embed-url", {
id: recording.id,
t: 80,
autoplay: true,
});
// -> /embed/<shareId>?t=80&autoplay=1
```
## Slack unfurls
Clips can render Loom-style Slack previews through Slack App Unfurling. Configure
the Slack app's `link_shared` event to call `/api/slack/unfurl`; the route
verifies `SLACK_SIGNING_SECRET`, acknowledges Slack URL verification, and calls
`chat.unfurl` with a Block Kit `video` block using the existing `/embed/:id`
player URL.
For installable workspaces, use the Clips Settings OAuth flow. `connect-slack`
opens Slack OAuth, `/api/slack/oauth/callback` stores the bot token encrypted in
`app_secrets`, and `slack_installations` stores only the Slack team/app metadata
plus the secret ref. The unfurl webhook resolves the token by Slack `team_id`
and `api_app_id`; only if no OAuth install exists should it fall back to the
legacy `SLACK_BOT_TOKEN` path.
The playable Slack embed is deliberately narrower than the share page:
- Only `ready` recordings with `visibility === "public"` can produce a video block.
- Password-protected, expired, archived, trashed, private, org-only, or still-processing clips must not produce a playable Slack block.
- Slack thumbnails use the same-origin proxy for stored thumbnails and the branded social image while one is unavailable. The proxy redirects to that image if stored content cannot be served; normal share-page metadata remains the fallback when no Slack app is installed.
- Do not put passwords, short-lived share tokens, raw provider URLs, or transcript text in Slack unfurl payloads.
Required Slack app setup:
- Bot scopes: `links:read`, `links:write`, `links.embed:write`
- Event subscription: `link_shared`
- App unfurl domains: the public Clips share domain, for example `clips.agent-native.com`
- Request URL: `https://<clips-host>/api/slack/unfurl`
- OAuth redirect URL: `https://<clips-host>/api/slack/oauth/callback`
- Deploy secrets: `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, and
`SLACK_SIGNING_SECRET`
## Agent-readable clips
When a user supplies a Clips link, read the clip through its published agent
context instead of stopping at the share page or transcript preview:
1. Use the exact `agentContextUrl` in the page's agent-readable instructions or
`application/agent-native+json` discovery payload. Preserve its `id` and any
`agent_access` query parameter on every related request.
2. Fetch `apis.transcript` for the complete transcript. Fetch each
`recommendedFrames[].url` as an image and inspect the returned pixels; when
more visual context is needed, request `apis.frame.urlTemplate` with an
`atMs` timestamp.
3. On an error, inspect the JSON `failureKind`, `error`, and `nextStep`. If a
context or transcript request has `failureKind: access`, explain that the
link does not grant access. For a private clip, ask its owner to open the
Clips Share menu, choose **Share with agents**, and send the generated link.
If a frame request has `failureKind: media` while the transcript works,
report that the stored media could not be retrieved; another link will not
fix it. If it has `failureKind: processing`, follow `nextStep`: extraction,
size-limit, and temporary storage errors do not prove that media is missing
or that the link lacks access.
If `failureKind: expired`, ask the owner to extend or remove the clip expiry
in the Share menu, save, then create and send a new **Share with agents**
link. Never claim to have seen frames that were not returned as images.
The JSON discovery payload and HTTP endpoints work without browser tools. A
WebMCP-capable browser is optional; its frame tool returns a URL, so the agent
must still fetch that URL as an image.
Recordings can expose URLs meant for external agents without handing over raw
video bytes:
| Endpoint | Meaning |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `/api/agent-context.json?id=<recordingId>` | Clip metadata, transcript summary, recommended frames, and API discovery links |
| `/api/agent-transcript.json?id=<recordingId>` | Timestamped transcript segments with `startMs`, `endMs`, `timestamp`, `range`, `text`, and optional `source`; add `maxSegments` to page and continue with the returned `nextStartIndex` |
| `/api/agent-frame.jpg?id=<recordingId>&atMs=<ms>` | JPEG frame extracted from the video at the requested original-video timestamp |
These endpoints follow the same access model as `/api/public-recording`, plus a
temporary agent-link path:
- Non-public clips return not found to anonymous callers.
- `create-recording-agent-link` resolves normal recording access, rejects
archived or trashed recordings, then mints a two-hour `agent_access` URL for
`/share/:recordingId`. The share page SSR advertises the agent context URL,
and the JSON endpoints accept the same scoped token.
- Expired clips return expired.
- Password-protected clips require `password=<pw>` once; successful JSON
responses include short-lived tokenized links so the plaintext password is not
copied into downstream agent prompts, browser history, or logs.
- If a discovery, context, or transcript payload reports `agentReadiness.state`
as `"preparing"` (the clip is `"uploading"` or `"processing"`), wait 15 seconds
and retry `agentContextUrl`. Do not open the share page, fetch frames, or draw
conclusions until the recording status is `"ready"`.
- If the context or transcript response reports `transcript.status` as
`"pending"`, wait 15-30 seconds and retry the context/transcript URL a few
times before falling back to frames or telling the user no transcript exists.
Long recordings are the common case here.
- If transcription failed because Builder transcription credits are exhausted,
tell the user to upgrade or connect Builder.io credits, or configure a Groq
key for backup speech-to-text. Generic OpenAI or Anthropic chat keys do not
transcribe Clips recordings.
- Frame extraction must use the checked recording media path and must not expose
raw provider URLs.
Public agent context also exposes the recording's redacted browser diagnostics:
the bounded relative event sequence as `browserDiagnostics.timeline`, including
navigation, click/input targets, console events, and request/response markers;
the console stream (all levels) as `browserDiagnostics.consoleLogs` and the
fetch/XHR stream as `browserDiagnostics.networkRequests` (method, sanitized URL
with query values redacted, status, duration), plus `consoleIssues` and
`failedNetworkRequests` highlights. All of it is bounded, and page URL, headers,
bodies, and cookies stay omitted.
Password-protected clips require the password once to mint a short-lived token,
which is returned inside the agent-context links.
Use the `@agent-native/core/server` and `@agent-native/core/shared` agent-access
helpers for scoped token mint/verify and bot-visible URL construction. Keep
Clips-specific visibility, password, transcript, frame, and player behavior in
Clips. New URLs should use `agent_access`; existing agent API routes should keep
accepting legacy `t` tokens so already-copied links do not break.
The share popover's "Share with agents" field should copy an agent context URL
or tokenized share page URL, not raw transcript text. Its "Copy agent prompt"
field may wrap that URL with instructions to fetch transcripts, frames, and
browser diagnostics, but it should still point agents at the context response so
they can fetch only the visual context they need.
HTTP access is the default browser-independent path: fetch the agent context
URL and then use its advertised transcript and frame URLs. It works for
URL-only clients without loading the share page.
When a `/share/:id`, `/embed/:id`, or public `/r/:id` page is open in a
WebMCP-capable browser, the page also registers these read-only tools:
| WebMCP tool | Purpose |
| --- | --- |
| `clips-get-context` | Clip metadata, readiness, transcript status, and HTTP API URLs |
| `clips-get-transcript` | Bounded timestamped segments, with optional time bounds and stable-index pagination; may omit fullText |
| `clips-get-frame` | An existing authenticated JPEG frame URL for `atMs` |
For any client, fetch the agent context URL and use its HTTP API URLs. For
complete transcript text, use `apis.transcript`. If the page is already open
in a WebMCP-capable browser, list its current page tools and use them for
bounded inspection; `clips-get-transcript` may omit `fullText` or return a
truncated result, so follow its `sourceUrl` for the complete HTTP transcript.
The transcript tool returns `nextStartIndex` when another page exists; pass
that value back as `startIndex` so overlapping transcript segments are not
skipped.
The URL endpoint accepts the same `startIndex`, `maxSegments`, `startMs`, and
`endMs` parameters, and keeps `nextStartMs` for older clients. The frame tool
returns an image URL and `mimeType: image/jpeg`; fetch that URL as an image
rather than expecting WebMCP to carry binary bytes. WebMCP is optional
progressive enhancement and page-local. HTTP access remains first-class and
browser-independent: use the existing `agentContextUrl`, `apis.transcript`,
and `apis.frame` URLs above. These URLs, password handling, scoped
`agent_access` tokens, and legacy `t` token support remain the primary URL
contract.
## View counting
Clips counts **human views** and **agent views** separately. The two live in
different tables and never mix — see "Agent views" below before touching either.
### Human views
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)` from `server/lib/recordings.ts`. Always go through it — do not recompute inline.
```ts
import { shouldCountView } from "../server/lib/recordings.js";
if (
Voir sur GitHub