| name | video-sharing |
| description | How Clips shares recordings — composes with the framework sharing skill and adds password, expiry, embed URLs, and view-counting. Use when wiring the share dialog, building embed links, adding a password, or debugging who can see a recording. |
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).
Clips adds two things on top of the framework system:
- Password — an optional bcrypt'd string on the
recordings row. When set, all non-owner viewers must enter it to play the recording.
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.
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.
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.
Access resolution
The player and /api/video/:id route check access in this exact order:
async function canAccess(
recordingId: string,
requester: Session | null,
providedPassword?: string,
) {
const access = await resolveAccess("recording", recordingId, requester);
if (!access.allowed) return false;
const rec = await getRecordingOrThrow(recordingId);
if (rec.expiresAt && requester?.email !== rec.ownerEmail) {
if (new Date(rec.expiresAt) < new Date()) return false;
}
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:
const { url } = await callAction("build-embed-url", {
id: recording.id,
t: 80,
autoplay: true,
});
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.
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 stored thumbnail (or animated thumbnail as fallback) and 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
Agent-readable public clips
Public recordings also expose URLs meant for external agents:
| 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 |
/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:
- Non-public clips return not found to anonymous callers.
- 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.
- Frame extraction must use the checked recording media path and must not expose
raw provider URLs.
The share popover's "Share with agents" field should copy the agent context URL,
not raw transcript text. The context response points agents at the transcript and
frame APIs so they can fetch only the visual context they need.
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) from server/lib/recordings.ts. Always go through it — do not recompute inline.
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-events 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.
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.
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.