Shape a video into a clear story
Plan a short video around a useful takeaway, strong sequence, and realistic production needs.
5,409,078 AI resources indexedWorking toward the world’s largest AI resource index.
Facilitates secure video sharing with password protection and expiry options, enhancing user control over access to recordings.
---
name: video-sharing-7b811b5620f4
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.
- **`recording_content_bindings`** — the Agent-Native Content documents a Clip is placed in. See "Clips in Content documents" below.
## Dropping in the share UI
Clips composes its share panels from Toolkit pieces rather than the generic
`ShareButton`. `app/components/player/share-dialog.tsx` (recordings) and
`app/components/meetings/share-meeting-dialog.tsx` (meetings) both use
`useShareButtonController`, `SharePeopleInvite`, `SharePeopleList`, and
`ShareGeneralAccessRow`, so invites, roles, and the organization confirmation
match every other app.
- **Tabs:** recordings show People, Agents, Embed, Social; meetings show People
and Agents. Keep that order. A viewer resharing someone else's clip gets no
People tab, and no Agents tab when the clip has a password.
- **Copy link** is the link segment of the Share button (`JoinedShareControl`),
not a row inside the panel.
- **Advanced** sits under General access, in `ShareGeneralAccessRow`'s
children, with a one-line summary beside it. For recordings it holds
Password required and Link expires, which call
`update-recording --password=...` / `--expiresAt=...`. For meetings it holds
Transcript on the share link (see the meetings skill).
- **Embed** never changes access itself. When the clip isn't public, it explains
why and links back to General access, which opens the access menu so the
change goes through the same confirmation as any other. Copy embed code stays
disabled until the clip is public.
Put any new generic sharing behavior in Toolkit, not in these Clips wrappers.
## 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.
## Clips in Content documents
A Clip placed in a Content document has one `recording_content_bindings` row per (recording, Content app, document). `origin` is `upload` (made in the document) or `link` (pasted); `state` is `active` or `trashed`. A duplicated document adds its own row.
- Only an active `upload` binding grants access: a Content reader with no access of their own becomes a viewer of that one recording (`server/lib/content-doc-grant.ts`, a host access resolver registered with `includeSignedOut: true` so it is asked about signed-out readers of a public document too). A reader's own Clips sharing, in their active workspace, still applies and is never lowered. A `link` binding, or none, leaves the Clip's own sharing in charge. When every binding is trashed, document readers lose access; the owner never does.
- The Clip's own rules still apply on top: Trash, takedown and expiry refuse document readers, a password-protected Clip does not play through a document, and with comments off document readers see no comments and cannot write.
- Content calls `/api/content-clips/:recordingId/playback` and `/comments` (GET `?limit=<n>` to list the first n thread starts, at most `CONTENT_CLIP_MAX_COMMENT_THREADS`, which is also what a request with no `limit` gets, with `truncated` when there are more and each thread's text and author cut to `CONTENT_CLIP_COMMENT_PREVIEW_CHARS` and `CONTENT_CLIP_COMMENT_AUTHOR_CHARS`, so Content can cap what it reads (playback likewise cuts the title to `CONTENT_CLIP_RECORDING_TITLE_CHARS`); POST to add, reply, edit, delete, resolve or react) server to server with a one-minute A2A token. `shared/content-clip-contract.ts` is the contract: audience `<Clips URL>/content-clips`, one scope, and `document_id` + `recording_id` claims. A signed-out reader of a public document gets the `content-clip:public-read` scope: playback and the comment list, never a write.
- The token is signed with `CONTENT_CLIPS_PEER_SECRET` (`clips.contentPeerSecret`, read with `resolveContentClipsPeerSecret`), a secret only the Content and Clips deployments hold, and Clips verifies it with that secret alone. That is what makes the token's issuer trustworthy as the Content app: a token signed with the deployment `A2A_SECRET` or a workspace secret is refused, because other apps hold those. While the secret is unset, shorter than 32 characters, or equal to `A2A_SECRET`, every route answers 503 with code `content_clips_not_configured`; set it on both deployments.
- Playback returns Clips media URLs with a five-minute `content_grant` that names the reader, not a role. The grant is signed with a key derived from `CONTENT_CLIPS_PEER_SECRET`, never the app's own server secret (which a workspace derives from `A2A_SECRET`), and a media request carrying a grant while the secret is unusable gets the same 503. `/api/video` and `/api/thumbnail` re-run every gate on each request, so trashing the binding stops a URL already handed out. An expired grant is a 403 with code `content_grant_expired`; Content's player then asks Content for playback again, which re-checks document access.
- Each reader's reads and writes of one Clip through one document are rate-limited per process. A request counts against the Clip's bucket only once Clips has opened it: Content mints a token for any recording id a page reader names, so counting earlier would let a reader name made-up ids until their own spent bucket dropped out. A full map drops its oldest bucket rather than refuse a new one. A request whose Clip doesn't open counts as a miss against that reader's misses for that Clip and document; once they are spent, the Clip is refused before any database read. Misses are kept per Clip so made-up ids can't use up a real Clip's, which leaves a caller who keeps naming new made-up ids unbounded here: each costs one lookup, and Content's own per-visitor limit has to bound it. Every signed-out reader of a document shares the public reader's buckets.
- A Content editor's upload uses the `content-clip:upload` scope, whose token carries `document_id`, the editor's `name`, and the browser's `client_ip` when Content knows it, never a `recording_id`. Content checks that the reader can edit the page before minting it. `GET /api/content-clips/upload-status` answers the editor's `storage` state (`ready`, `free` with what's left, `used_up`, `none`, or `account_required` when the email has no Clips account or workspace; Clips never makes either). `POST /api/content-clips/uploads` adds the `upload` binding first, then a private recording the editor owns, and returns a ticketed `chunkUrl` and `cancelUrl`; a state that can't take the file is a 409 `storage_required` with that `storage`. The start carries the `uploadKey` Content makes once per picked file, kept on the binding with the file's type and size: a start repeated with the same key, page, and editor never makes a second Clip. It answers that upload with another ticket and the first start's plan while it is uploading, a 409 `upload_already_finished` with `recordingId` and `status` once a final chunk holds it or Clips is making the Clip, a 409 `upload_starting` while the latest start is still making its recording (Content repeats it after a few seconds), or a 409 `upload_key_used` when it was cancelled or failed, is parked until storage is connected, began in another block, or describes a file of another type or size. A start that fails deletes its binding only when nothing can be left: a refusal raised before any recording or free clip (`refusedStart`), or an error with `cleanupConfirmed: true`. Any other failure keeps the binding and marks `upload_start_failed_at`, and a repeat that finds no recording under a marked binding, or one whose latest attempt is `RESERVATION_GRACE_MS` old, takes the start over with a compare-and-swap on the binding (`takeOverStart`) and makes the recording under the binding's own recording id, so the free clip reserved per recording id is never held twice; of repeats that overlap, the rest get `upload_starting`. A kept binding whose start left a recording answers from where that upload stands, so one whose recording failed answers `upload_key_used` like a cancelled one; that key's upload ended, and Content starts the file again under a new key. Earlier tickets keep working, since overlapping repeats each hand one back: a recording keeps its four newest (`addRecordingUploadTicket`) and still holds one upload slot, while an agent's `create-recording-upload` keeps one. A repeat skips the storage check, since its first start may have taken the last free clip.
- The editor's browser posts the file to `/api/uploads/:recordingId/content-chunk` with the ticket in the query, no cookie, and no custom headers, so the request is a CORS simple request and no preflight reaches the auth guard. Only the paired built-in Content's origin (`pairedContentOrigin`) gets `Access-Control-Allow-Origin`, never the request's own `Origin`, and the answer never allows credentials.
- When the page gives an upload up (its block is gone, the editor can no longer edit, or a chunk failed for good), the browser POSTs `cancelUrl` (`/api/uploads/:recordingId/content-cancel?ticket=…`) the same way. Clips' own abort fails the recording and releases its free clip, as the lease reaper would an hour later; the `upload` binding stays. Where an upload stands is one answer, `readUploadPhase` in `server/lib/final-chunk-claim.ts`: `uploading`, `finishing` (a final chunk holds it), `parked` (Clips has the whole file and waits for storage), `finished` (`processing` or `ready`) or `ended` (cancelled or failed). The repeated start, the cancel and the Content chunk route all use it, and nothing else reads its three stores (the final chunk's claim, the upload state and the recording row) together. It reads the claim first, then the upload state, then the row. Before Clips takes the final chunk's bytes, on the buffered and the resumable path alike, the Content chunk route renews the row's lease to outlast its claim, only while the row is `uploading`, then claims the upload, changing its upload state with the same compare-and-swap the abort claims with; once Clips accepts the chunk it writes `parked` or `finished` into the upload state, and only then gives the claim up, so a reader that finds no claim finds where the upload went. A final chunk that fails gives the claim up with the upload still `uploading`. A claim older than `FINAL_CHUNK_CLAIM_TTL_MS` (a little over Clips' 75 s function timeout) was left by a request that was cut off, so the upload reads `uploading` again and the next final chunk or cancel takes it over. A live claim is never taken over: a cancel or another send of the final chunk changes nothing while the upload is `finishing` and answers 409 `upload_finishing` (`CONTENT_CLIP_CHUNK_FINISHING` for the chunk), which Content repeats every few seconds, the chunk for a little longer than the claim lasts. A final chunk sent once the upload is `parked` or `finished` answers the first one's success (202 `waitingForStorage`, or 200 with `status`) and Clips takes no bytes; one that finds the upload `ended` is refused (409). The answer's `outcome` comes from the phase read after the abort, never from the abort's `ok`: `cancelled` (200) once it is `ended`, a repeat included and a parked upload too, or `already_finished` (409, with `status`) once it is `finished`. Any other answer means it wasn't cancelled; an upload whose cancels keep finding `finishing` is left to the reaper. The reaper reads only the row's lease, never the phase: it fails a row whose lease ran out in one conditional update, so it either failed the row before the final chunk renewed it, and the chunk finds it `ended`, or it leaves the row. A page in Trash can still cancel. A closed tab is still left to the reaper.
- Upload-origin Clips are listed under From Content (`list-recordings` view `from-content`, each with `contentPageUrl` back to its page), and Library's own list leaves them out. A folder someone moved one into still lists it, and linked Clips stay in Library.
- `isActiveUploadBinding` / `activeUploadBinding()` in `server/lib/content-uploads.ts` is the one test for an active upload binding. Playback's grant, the chunk route, From Content and its page links all use it, so an upload whose page is in Trash plays for no document reader, takes no chunks (409), and lists in neither From Content nor Library.
- Create and change binding rows only through Content's upload and lifecycle flows.
## 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. To watch motion (an animation, a transition, a cursor
path), request `apis.frames.urlTemplate` with `startMs`, `endMs` and `fps`:
one JPEG contact sheet of up to 36 consecutive frames from a range of at
most 60 seconds. Cells read left to right, then top to bottom; cell `i`
shows the frame on screen at `startMs + i * 1000 / fps`, and solid black
cells at the end of the grid hold no frame. Without `fps` the sheet uses
the video's own frame rate, and without `endMs` it takes up to 36 frames
from `startMs`, stopping at 60 seconds or the end of the clip. A
`failureKind: request` response names a request that fits in `nextStep`.
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 |
| `/api/agent-frames.jpg?id=<recordingId>&startMs=<ms>&endMs=<ms>&fps=<n>` | One JPEG contact sheet of up to 36 consecutive frames from a range; the `X-Clips-Frame-Sheet-Fps`, `-Grid` and `-Times` headers describe the cells |
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. When a list is trimmed, failures and
pre-recording entries are kept before the newest.
When the clip carries app activity from an agent-native app, the context adds
`appActivity` (counts, recorder coverage windows, up to 20 failures with
`elapsedMs`). For the owner, or a Share with agents link made by someone who
can still edit the clip, it also lists `apis.appActivity`
(`/api/agent-app-activity.json`, filtered by `fromMs`, `toMs`, `maxEvents`,
continued with `start=nextStart`). An open public link, a share password, or a
link made by a viewer gets the summary only, because event data is more
sensitive than the summary.
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 (
!viewer.countedView &&
shouldCountView(viewer.totalWatchMs, viewer.completedPct, scrubbedToEnd)
) {
await db
.update(schema.recordingViewers)
.set({ countedView: true })
.where(eq(schema.recordingViewers.id, viewer.id));
}
```
Events feeding this live in `recording_events`. The `/api/view-event` route receives `view-start`, `watch-progress` (every 5s), `seek`, `pause`, `resume`, `cta-click`, `reaction`. Aggregate into `recording_viewers` on write to keep `get-insights` fast.
### Agent views
An **agent view** is an outside agent reading a clip through its public agent
APIs — `/api/agent-context.json`, `/api/agent-transcript.json`,
`/api/agent-frame.jpg`, `/api/agent-frames.jpg`. Those routes are agent-only surfaces (a human watching a
clip never hits them), so a request on one is the signal.
- **Table:** `recording_agent_views` — one row per `(recordingId, agentKey, viewSessionId)`.
`agentKey` is a sha256 of user-agent + request IP, so an agent is countable
across polls without ever storing its IP. `agentLabel` resolves in that order:
the label the agent link was minted with (a signed `agentLabel` claim on the
`agent_access` token, set via `create-recording-agent-link --agentLabel`), then
the product name parsed from the user-agent (Claude, ChatGPT, Perplexity, …),
then NULL. NULL means unnamed, not a name — render it as "Unknown agent" and
never write a placeholder string, or an agent we could not identify becomes
indistinguishable from one that identified itself. `userAgent` keeps the raw
(truncated) string so unnamed agents stay identifiable and new `AGENT_LABELS`
patterns come from real traffic.
- **Where it's written:** `recordAgentView` in `server/lib/agent-views.ts`,
called from `loadPublicAgentAccess` — the one choke point all three agent
routes share. Owner requests are skipped (they're previews, not views), and the
write is best-effort so view accounting can never fail an agent's read.
- **Dedup:** one agent's burst of context + transcript + frame polls collapses
into a single view via a 30-minute window (`AGENT_VIEW_SESSION_MS`), with
`requestCount` recording how many polls that view covered.
- **Reads:** `countRecordingAgentViews` and `listRecordingAgentViewers`. Surfaced
as `agentViews` / `agentViewers` on `get-recording-insights`, `agentViewCount`
on `get-recording-player-data`, `list-recordings`, and the public
`/api/public-recording` payload. The count renders inline — on the views pill
itself (`RecordingViewsBadge`, watch and share headers) and on library cards —
so agent reads are visible without opening the popover. It is always a
separate icon-prefixed number, never summed into the human view total.
Keeping this in its own table is deliberate: no human-view query can pick agents
up by forgetting a filter. Do not add agent rows to `recording_viewers` or
`recording_views`.
### Per-viewer view records ("Viewed by")
On top of the aggregate `viewCount` shown in the library and the `views` stat in the insights panel, Clips records **individual view records** — who viewed a clip and when — so the owner can see a timeline, not just a number.
- **Table:** `recording_views` (`server/db/schema.ts`) — `id`, `recordingId`, `viewerId` (FK to `recording_viewers.id`), denormalized `viewerEmail` / `viewerName`, `viewedAt`. Append-only; never updated after insert.
- **Where it's written:** `server/routes/api/view-event.post.ts`, in the same handler that already upserts `recording_viewers` and inserts `recording_events`. A `recording_views` row is inserted **exactly once per viewer**, at the moment `countedView` transitions from `false` to `true` (i.e. the same instant that viewer starts contributing to the aggregate `views` count in `get-recording-insights`). This keeps the per-viewer log and the aggregate count always consistent — a returning viewer who is already counted does not create a second row.
- **Anonymous viewers** still get a row — `viewerEmail` is `null` and `viewerName` holds the `anon:<sessionId>` key, same convention as `recording_viewers`. The UI renders these as "Someone".
- **Read surface:** `list-clip-views` action — `{ recordingId, limit? }`, owner-only (`assertAccess("recording", recordingId, "editor")`), returns `{ views: [{ id, viewerEmail, viewerName, viewedAt }] }` sorted most-recent-first. Use this instead of scanning `recording_viewers`/`recording_events` when you need a real per-visit timeline.
- **UI:** clicking the view count (library card or the insights panel's Views stat) opens `<ViewedByPopover recordingId>` (`app/components/sharing/viewed-by-popover.tsx`), which lazily queries `list-clip-views` only while the popover is open.
- **Privacy:** viewer identities in `recording_views` are visible only to principals who already pass the owner-only `assertAccess` check on the recording — never surfaced on the public share page itself, which never fetches or renders other viewers' data.
## Anonymous viewers
`recording_viewers.viewer_email` is **nullable** — anonymous viewers (public link, no account) still get a row keyed by a cookie id. Never require login to watch a public recording; require it only when the share grant is user-scoped.
## Rules
- **Never** write to `recording_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/video/:id`. Streaming routes are the #1 data-leak vector.
- **Password + expiry are additions**, not replacements — the framework's `accessFilter` still runs first.
- The embed route (`/embed/:shareId`) is **anonymous by default** — don't require auth, but still go through `canAccess`.
- `build-embed-url` is the single source of truth for embed URLs — keep it in sync with the query params the player accepts.
- **Never** expose `recording_views` rows (or any other viewer's identity) from the public share/embed page — only `list-clip-views`, which is owner-only via `assertAccess`, may return them.
## Related skills
- `sharing` — framework-level primitive Clips composes with. Read this first.
- `security` — password handling, token storage, anonymous viewer cookies.
- `video-editing` — exports honor `recordings.enableDownloads`.
- `storing-data` — why `password` / `expiresAt` live on the `recordings` row instead of a parallel table.
---
name: video-sharing-7b811b5620f4
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.
- **`recording_content_bindings`** — the Agent-Native Content documents a Clip is placed in. See "Clips in Content documents" below.
## Dropping in the share UI
Clips composes its share panels from Toolkit pieces rather than the generic
`ShareButton`. `app/components/player/share-dialog.tsx` (recordings) and
`app/components/meetings/share-meeting-dialog.tsx` (meetings) both use
`useShareButtonController`, `SharePeopleInvite`, `SharePeopleList`, and
`ShareGeneralAccessRow`, so invites, roles, and the organization confirmation
match every other app.
- **Tabs:** recordings show People, Agents, Embed, Social; meetings show People
and Agents. Keep that order. A viewer resharing someone else's clip gets no
People tab, and no Agents tab when the clip has a password.
- **Copy link** is the link segment of the Share button (`JoinedShareControl`),
not a row inside the panel.
- **Advanced** sits under General access, in `ShareGeneralAccessRow`'s
children, with a one-line summary beside it. For recordings it holds
Password required and Link expires, which call
`update-recording --password=...` / `--expiresAt=...`. For meetings it holds
Transcript on the share link (see the meetings skill).
- **Embed** never changes access itself. When the clip isn't public, it explains
why and links back to General access, which opens the access menu so the
change goes through the same confirmation as any other. Copy embed code stays
disabled until the clip is public.
Put any new generic sharing behavior in Toolkit, not in these Clips wrappers.
## 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.
## Clips in Content documents
A Clip placed in a Content document has one `recording_content_bindings` row per (recording, Content app, document). `origin` is `upload` (made in the document) or `link` (pasted); `state` is `active` or `trashed`. A duplicated document adds its own row.
- Only an active `upload` binding grants access: a Content reader with no access of their own becomes a viewer of that one recording (`server/lib/content-doc-grant.ts`, a host access resolver registered with `includeSignedOut: true` so it is asked about signed-out readers of a public document too). A reader's own Clips sharing, in their active workspace, still applies and is never lowered. A `link` binding, or none, leaves the Clip's own sharing in charge. When every binding is trashed, document readers lose access; the owner never does.
- The Clip's own rules still apply on top: Trash, takedown and expiry refuse document readers, a password-protected Clip does not play through a document, and with comments off document readers see no comments and cannot write.
- Content calls `/api/content-clips/:recordingId/playback` and `/comments` (GET `?limit=<n>` to list the first n thread starts, at most `CONTENT_CLIP_MAX_COMMENT_THREADS`, which is also what a request with no `limit` gets, with `truncated` when there are more and each thread's text and author cut to `CONTENT_CLIP_COMMENT_PREVIEW_CHARS` and `CONTENT_CLIP_COMMENT_AUTHOR_CHARS`, so Content can cap what it reads (playback likewise cuts the title to `CONTENT_CLIP_RECORDING_TITLE_CHARS`); POST to add, reply, edit, delete, resolve or react) server to server with a one-minute A2A token. `shared/content-clip-contract.ts` is the contract: audience `<Clips URL>/content-clips`, one scope, and `document_id` + `recording_id` claims. A signed-out reader of a public document gets the `content-clip:public-read` scope: playback and the comment list, never a write.
- The token is signed with `CONTENT_CLIPS_PEER_SECRET` (`clips.contentPeerSecret`, read with `resolveContentClipsPeerSecret`), a secret only the Content and Clips deployments hold, and Clips verifies it with that secret alone. That is what makes the token's issuer trustworthy as the Content app: a token signed with the deployment `A2A_SECRET` or a workspace secret is refused, because other apps hold those. While the secret is unset, shorter than 32 characters, or equal to `A2A_SECRET`, every route answers 503 with code `content_clips_not_configured`; set it on both deployments.
- Playback returns Clips media URLs with a five-minute `content_grant` that names the reader, not a role. The grant is signed with a key derived from `CONTENT_CLIPS_PEER_SECRET`, never the app's own server secret (which a workspace derives from `A2A_SECRET`), and a media request carrying a grant while the secret is unusable gets the same 503. `/api/video` and `/api/thumbnail` re-run every gate on each request, so trashing the binding stops a URL already handed out. An expired grant is a 403 with code `content_grant_expired`; Content's player then asks Content for playback again, which re-checks document access.
- Each reader's reads and writes of one Clip through one document are rate-limited per process. A request counts against the Clip's bucket only once Clips has opened it: Content mints a token for any recording id a page reader names, so counting earlier would let a reader name made-up ids until their own spent bucket dropped out. A full map drops its oldest bucket rather than refuse a new one. A request whose Clip doesn't open counts as a miss against that reader's misses for that Clip and document; once they are spent, the Clip is refused before any database read. Misses are kept per Clip so made-up ids can't use up a real Clip's, which leaves a caller who keeps naming new made-up ids unbounded here: each costs one lookup, and Content's own per-visitor limit has to bound it. Every signed-out reader of a document shares the public reader's buckets.
- A Content editor's upload uses the `content-clip:upload` scope, whose token carries `document_id`, the editor's `name`, and the browser's `client_ip` when Content knows it, never a `recording_id`. Content checks that the reader can edit the page before minting it. `GET /api/content-clips/upload-status` answers the editor's `storage` state (`ready`, `free` with what's left, `used_up`, `none`, or `account_required` when the email has no Clips account or workspace; Clips never makes either). `POST /api/content-clips/uploads` adds the `upload` binding first, then a private recording the editor owns, and returns a ticketed `chunkUrl` and `cancelUrl`; a state that can't take the file is a 409 `storage_required` with that `storage`. The start carries the `uploadKey` Content makes once per picked file, kept on the binding with the file's type and size: a start repeated with the same key, page, and editor never makes a second Clip. It answers that upload with another ticket and the first start's plan while it is uploading, a 409 `upload_already_finished` with `recordingId` and `status` once a final chunk holds it or Clips is making the Clip, a 409 `upload_starting` while the latest start is still making its recording (Content repeats it after a few seconds), or a 409 `upload_key_used` when it was cancelled or failed, is parked until storage is connected, began in another block, or describes a file of another type or size. A start that fails deletes its binding only when nothing can be left: a refusal raised before any recording or free clip (`refusedStart`), or an error with `cleanupConfirmed: true`. Any other failure keeps the binding and marks `upload_start_failed_at`, and a repeat that finds no recording under a marked binding, or one whose latest attempt is `RESERVATION_GRACE_MS` old, takes the start over with a compare-and-swap on the binding (`takeOverStart`) and makes the recording under the binding's own recording id, so the free clip reserved per recording id is never held twice; of repeats that overlap, the rest get `upload_starting`. A kept binding whose start left a recording answers from where that upload stands, so one whose recording failed answers `upload_key_used` like a cancelled one; that key's upload ended, and Content starts the file again under a new key. Earlier tickets keep working, since overlapping repeats each hand one back: a recording keeps its four newest (`addRecordingUploadTicket`) and still holds one upload slot, while an agent's `create-recording-upload` keeps one. A repeat skips the storage check, since its first start may have taken the last free clip.
- The editor's browser posts the file to `/api/uploads/:recordingId/content-chunk` with the ticket in the query, no cookie, and no custom headers, so the request is a CORS simple request and no preflight reaches the auth guard. Only the paired built-in Content's origin (`pairedContentOrigin`) gets `Access-Control-Allow-Origin`, never the request's own `Origin`, and the answer never allows credentials.
- When the page gives an upload up (its block is gone, the editor can no longer edit, or a chunk failed for good), the browser POSTs `cancelUrl` (`/api/uploads/:recordingId/content-cancel?ticket=…`) the same way. Clips' own abort fails the recording and releases its free clip, as the lease reaper would an hour later; the `upload` binding stays. Where an upload stands is one answer, `readUploadPhase` in `server/lib/final-chunk-claim.ts`: `uploading`, `finishing` (a final chunk holds it), `parked` (Clips has the whole file and waits for storage), `finished` (`processing` or `ready`) or `ended` (cancelled or failed). The repeated start, the cancel and the Content chunk route all use it, and nothing else reads its three stores (the final chunk's claim, the upload state and the recording row) together. It reads the claim first, then the upload state, then the row. Before Clips takes the final chunk's bytes, on the buffered and the resumable path alike, the Content chunk route renews the row's lease to outlast its claim, only while the row is `uploading`, then claims the upload, changing its upload state with the same compare-and-swap the abort claims with; once Clips accepts the chunk it writes `parked` or `finished` into the upload state, and only then gives the claim up, so a reader that finds no claim finds where the upload went. A final chunk that fails gives the claim up with the upload still `uploading`. A claim older than `FINAL_CHUNK_CLAIM_TTL_MS` (a little over Clips' 75 s function timeout) was left by a request that was cut off, so the upload reads `uploading` again and the next final chunk or cancel takes it over. A live claim is never taken over: a cancel or another send of the final chunk changes nothing while the upload is `finishing` and answers 409 `upload_finishing` (`CONTENT_CLIP_CHUNK_FINISHING` for the chunk), which Content repeats every few seconds, the chunk for a little longer than the claim lasts. A final chunk sent once the upload is `parked` or `finished` answers the first one's success (202 `waitingForStorage`, or 200 with `status`) and Clips takes no bytes; one that finds the upload `ended` is refused (409). The answer's `outcome` comes from the phase read after the abort, never from the abort's `ok`: `cancelled` (200) once it is `ended`, a repeat included and a parked upload too, or `already_finished` (409, with `status`) once it is `finished`. Any other answer means it wasn't cancelled; an upload whose cancels keep finding `finishing` is left to the reaper. The reaper reads only the row's lease, never the phase: it fails a row whose lease ran out in one conditional update, so it either failed the row before the final chunk renewed it, and the chunk finds it `ended`, or it leaves the row. A page in Trash can still cancel. A closed tab is still left to the reaper.
- Upload-origin Clips are listed under From Content (`list-recordings` view `from-content`, each with `contentPageUrl` back to its page), and Library's own list leaves them out. A folder someone moved one into still lists it, and linked Clips stay in Library.
- `isActiveUploadBinding` / `activeUploadBinding()` in `server/lib/content-uploads.ts` is the one test for an active upload binding. Playback's grant, the chunk route, From Content and its page links all use it, so an upload whose page is in Trash plays for no document reader, takes no chunks (409), and lists in neither From Content nor Library.
- Create and change binding rows only through Content's upload and lifecycle flows.
## 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. To watch motion (an animation, a transition, a cursor
path), request `apis.frames.urlTemplate` with `startMs`, `endMs` and `fps`:
one JPEG contact sheet of up to 36 consecutive frames from a range of at
most 60 seconds. Cells read left to right, then top to bottom; cell `i`
shows the frame on screen at `startMs + i * 1000 / fps`, and solid black
cells at the end of the grid hold no frame. Without `fps` the sheet uses
the video's own frame rate, and without `endMs` it takes up to 36 frames
from `startMs`, stopping at 60 seconds or the end of the clip. A
`failureKind: request` response names a request that fits in `nextStep`.
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 |
| `/api/agent-frames.jpg?id=<recordingId>&startMs=<ms>&endMs=<ms>&fps=<n>` | One JPEG contact sheet of up to 36 consecutive frames from a range; the `X-Clips-Frame-Sheet-Fps`, `-Grid` and `-Times` headers describe the cells |
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. When a list is trimmed, failures and
pre-recording entries are kept before the newest.
When the clip carries app activity from an agent-native app, the context adds
`appActivity` (counts, recorder coverage windows, up to 20 failures with
`elapsedMs`). For the owner, or a Share with agents link made by someone who
can still edit the clip, it also lists `apis.appActivity`
(`/api/agent-app-activity.json`, filtered by `fromMs`, `toMs`, `maxEvents`,
continued with `start=nextStart`). An open public link, a share password, or a
link made by a viewer gets the summary only, because event data is more
sensitive than the summary.
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 (
!viewer.countedView &&
shouldCountView(viewer.totalWatchMs, viewer.completedPct, scrubbedToEnd)
) {
await db
.update(schema.recordingViewers)
.set({ countedView: true })
.where(eq(schema.recordingViewers.id, viewer.id));
}
```
Events feeding this live in `recording_events`. The `/api/view-event` route receives `view-start`, `watch-progress` (every 5s), `seek`, `pause`, `resume`, `cta-click`, `reaction`. Aggregate into `recording_viewers` on write to keep `get-insights` fast.
### Agent views
An **agent view** is an outside agent reading a clip through its public agent
APIs — `/api/agent-context.json`, `/api/agent-transcript.json`,
`/api/agent-frame.jpg`, `/api/agent-frames.jpg`. Those routes are agent-only surfaces (a human watching a
clip never hits them), so a request on one is the signal.
- **Table:** `recording_agent_views` — one row per `(recordingId, agentKey, viewSessionId)`.
`agentKey` is a sha256 of user-agent + request IP, so an agent is countable
across polls without ever storing its IP. `agentLabel` resolves in that order:
the label the agent link was minted with (a signed `agentLabel` claim on the
`agent_access` token, set via `create-recording-agent-link --agentLabel`), then
the product name parsed from the user-agent (Claude, ChatGPT, Perplexity, …),
then NULL. NULL means unnamed, not a name — render it as "Unknown agent" and
never write a placeholder string, or an agent we could not identify becomes
indistinguishable from one that identified itself. `userAgent` keeps the raw
(truncated) string so unnamed agents stay identifiable and new `AGENT_LABELS`
patterns come from real traffic.
- **Where it's written:** `recordAgentView` in `server/lib/agent-views.ts`,
called from `loadPublicAgentAccess` — the one choke point all three agent
routes share. Owner requests are skipped (they're previews, not views), and the
write is best-effort so view accounting can never fail an agent's read.
- **Dedup:** one agent's burst of context + transcript + frame polls collapses
into a single view via a 30-minute window (`AGENT_VIEW_SESSION_MS`), with
`requestCount` recording how many polls that view covered.
- **Reads:** `countRecordingAgentViews` and `listRecordingAgentViewers`. Surfaced
as `agentViews` / `agentViewers` on `get-recording-insights`, `agentViewCount`
on `get-recording-player-data`, `list-recordings`, and the public
`/api/public-recording` payload. The count renders inline — on the views pill
itself (`RecordingViewsBadge`, watch and share headers) and on library cards —
so agent reads are visible without opening the popover. It is always a
separate icon-prefixed number, never summed into the human view total.
Keeping this in its own table is deliberate: no human-view query can pick agents
up by forgetting a filter. Do not add agent rows to `recording_viewers` or
`recording_views`.
### Per-viewer view records ("Viewed by")
On top of the aggregate `viewCount` shown in the library and the `views` stat in the insights panel, Clips records **individual view records** — who viewed a clip and when — so the owner can see a timeline, not just a number.
- **Table:** `recording_views` (`server/db/schema.ts`) — `id`, `recordingId`, `viewerId` (FK to `recording_viewers.id`), denormalized `viewerEmail` / `viewerName`, `viewedAt`. Append-only; never updated after insert.
- **Where it's written:** `server/routes/api/view-event.post.ts`, in the same handler that already upserts `recording_viewers` and inserts `recording_events`. A `recording_views` row is inserted **exactly once per viewer**, at the moment `countedView` transitions from `false` to `true` (i.e. the same instant that viewer starts contributing to the aggregate `views` count in `get-recording-insights`). This keeps the per-viewer log and the aggregate count always consistent — a returning viewer who is already counted does not create a second row.
- **Anonymous viewers** still get a row — `viewerEmail` is `null` and `viewerName` holds the `anon:<sessionId>` key, same convention as `recording_viewers`. The UI renders these as "Someone".
- **Read surface:** `list-clip-views` action — `{ recordingId, limit? }`, owner-only (`assertAccess("recording", recordingId, "editor")`), returns `{ views: [{ id, viewerEmail, viewerName, viewedAt }] }` sorted most-recent-first. Use this instead of scanning `recording_viewers`/`recording_events` when you need a real per-visit timeline.
- **UI:** clicking the view count (library card or the insights panel's Views stat) opens `<ViewedByPopover recordingId>` (`app/components/sharing/viewed-by-popover.tsx`), which lazily queries `list-clip-views` only while the popover is open.
- **Privacy:** viewer identities in `recording_views` are visible only to principals who already pass the owner-only `assertAccess` check on the recording — never surfaced on the public share page itself, which never fetches or renders other viewers' data.
## Anonymous viewers
`recording_viewers.viewer_email` is **nullable** — anonymous viewers (public link, no account) still get a row keyed by a cookie id. Never require login to watch a public recording; require it only when the share grant is user-scoped.
## Rules
- **Never** write to `recording_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/video/:id`. Streaming routes are the #1 data-leak vector.
- **Password + expiry are additions**, not replacements — the framework's `accessFilter` still runs first.
- The embed route (`/embed/:shareId`) is **anonymous by default** — don't require auth, but still go through `canAccess`.
- `build-embed-url` is the single source of truth for embed URLs — keep it in sync with the query params the player accepts.
- **Never** expose `recording_views` rows (or any other viewer's identity) from the public share/embed page — only `list-clip-views`, which is owner-only via `assertAccess`, may return them.
## Related skills
- `sharing` — framework-level primitive Clips composes with. Read this first.
- `security` — password handling, token storage, anonymous viewer cookies.
- `video-editing` — exports honor `recordings.enableDownloads`.
- `storing-data` — why `password` / `expiresAt` live on the `recordings` row instead of a parallel table.
Imported from the listed AgentSkill.sh category. Complete supplied package files and notices are retained. Required external tools and credentials are not installed. No bundled code was executed during import. Individual agent task behavior has not been tested.