diff --git a/README.md b/README.md
index 2485268..e7b89c3 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,9 @@ Clone this repository, or open the directory of the example you want. Each examp
## Examples
+- [agency-client-social-videos](examples/agency-client-social-videos) renders one short vertical video per agency client from that client's own photos and video clips, ending on a card in the client's brand color. The Edit JSON is built in code, one clip per media item.
- [bulk-csv-videos](examples/bulk-csv-videos) renders one video per row of a CSV from a single template with merge fields, tracked in a resumable manifest, with an optional AI step where Claude writes each row's headline and image prompt. Companion code for [Generate videos in bulk with an API and an AI agent](https://shotstack.io/learn/bulk-create-videos-from-csv-and-ai/).
+- [elevenlabs-voiceover-video](examples/elevenlabs-voiceover-video) narrates a video in an ElevenLabs voice, cloned from your own sample if you want, uploads the audio through the Ingest API and renders a captioned video.
- [first-render](examples/first-render) the very basics: submit an Edit, poll the render status, and print the output URL, in Node.js and Python. Start here if you are new to the API. Companion code for [Render your first video with the Shotstack API](https://shotstack.io/learn/render-your-first-video-shotstack-api/).
- [in-app-video-creation](examples/in-app-video-creation) lets a user create the same promo video three ways: an embedded Studio SDK editor, a quick form, and a one-click headless render, all through one render proxy that keeps the API key server-side. Companion code for [Add video creation to your app without building an editor](https://shotstack.io/learn/add-video-creation-to-your-app/).
- [instagram-ai-video](examples/instagram-ai-video) generates a script, voiceover and background image with AI, renders a 1080x1920 video, and publishes it as an Instagram Reel. Companion code for [How to automate Instagram posts with AI video](https://shotstack.io/learn/automate-instagram-posts-with-ai-video/).
diff --git a/examples/agency-client-social-videos/.env.example b/examples/agency-client-social-videos/.env.example
new file mode 100644
index 0000000..77bd08f
--- /dev/null
+++ b/examples/agency-client-social-videos/.env.example
@@ -0,0 +1,6 @@
+# Your Shotstack sandbox API key: https://dashboard.shotstack.io/register
+SHOTSTACK_API_KEY=
+
+# Optional. stage (sandbox, default) or v1 (production). Use the key from the same environment.
+# Both keys are in the dashboard under API Keys: https://dashboard.shotstack.io/
+SHOTSTACK_ENV=
diff --git a/examples/agency-client-social-videos/.gitignore b/examples/agency-client-social-videos/.gitignore
new file mode 100644
index 0000000..09c29c2
--- /dev/null
+++ b/examples/agency-client-social-videos/.gitignore
@@ -0,0 +1,2 @@
+.env
+renders.jsonl
diff --git a/examples/agency-client-social-videos/README.md b/examples/agency-client-social-videos/README.md
new file mode 100644
index 0000000..dd1d82e
--- /dev/null
+++ b/examples/agency-client-social-videos/README.md
@@ -0,0 +1,72 @@
+# Agency client social videos
+
+Render one short vertical video for each client an agency manages, from that client's own photos
+and video clips. Each client record has a name, a brand color, a brand mark and a list of media.
+The script builds the Edit JSON in code, with one clip for each item, so the video is as long as
+the client has media. You get one 1080 x 1920 MP4 per client, and a file that records which render
+belongs to which client.
+
+## Requirements
+
+- A [Shotstack account](https://dashboard.shotstack.io/register) and your **sandbox** API key
+- Node.js 20 or later
+
+Sandbox renders are watermarked. Your account needs at least one credit to use the sandbox.
+
+## Setup
+
+```bash
+git clone https://github.com/shotstack/shotstack-cookbook.git
+cd shotstack-cookbook/examples/agency-client-social-videos
+```
+
+Copy the environment file. Add your sandbox key to `.env`.
+
+```bash
+cp .env.example .env
+```
+
+Load the file into your shell. Do this in each new terminal:
+
+```bash
+set -a
+source .env
+set +a
+```
+
+## Run
+
+Submit one render per client:
+
+```bash
+node render.mjs
+```
+
+Then check them:
+
+```bash
+node status.mjs
+```
+
+Run `status.mjs` again until each render shows `done`.
+
+## What happens
+
+`render.mjs` reads `clients.json` and checks each client. A client needs a slug, a name, a hex
+brand color, an SVG brand mark and at least one media item. Each media item needs a type, image or
+video, and an HTTPS URL. The script reports every problem it finds, then stops. For each client it
+builds an Edit JSON and submits one render. The media plays in sequence, 4 seconds each,
+cropped to fill the vertical frame. Images get a slow zoom. Video clips play without their own
+sound. The video ends on a two second card in the brand color with the brand mark. A music track
+plays under the whole video. The script appends one line per render to `renders.jsonl` and ends
+with the count of submitted renders.
+
+`status.mjs` reads `renders.jsonl` and checks each render once. A `done` render prints its video
+URL.
+
+The sample has one client. Add a record to `clients.json` for each client you want to render. Media URLs must be public HTTPS
+URLs. To change the timing or the end card, edit `edit.mjs`.
+
+`renders.jsonl` only appends. Delete the file to start a new batch.
+
+To render in production, set `SHOTSTACK_ENV=v1` and put your production key in `.env`.
diff --git a/examples/agency-client-social-videos/api.mjs b/examples/agency-client-social-videos/api.mjs
new file mode 100644
index 0000000..45a44bb
--- /dev/null
+++ b/examples/agency-client-social-videos/api.mjs
@@ -0,0 +1,88 @@
+const API_KEY = process.env.SHOTSTACK_API_KEY;
+const ENV = process.env.SHOTSTACK_ENV || 'stage';
+const API = `https://api.shotstack.io/edit/${ENV}`;
+const TIMEOUT_MS = 30_000;
+
+export function fail(message) {
+ console.error(message);
+ process.exit(1);
+}
+
+export function requireConfig() {
+ if (!API_KEY) {
+ fail('Set SHOTSTACK_API_KEY before you run this script.');
+ }
+
+ if (!['stage', 'v1'].includes(ENV)) {
+ fail('SHOTSTACK_ENV must be stage or v1.');
+ }
+}
+
+// A rejected key or a dead network fails every record the same way. The
+// caller stops the batch at the first one instead of printing a line per record.
+function stopBatch(message) {
+ return Object.assign(new Error(message), { fatal: true });
+}
+
+async function apiError(response) {
+ const text = await response.text();
+
+ try {
+ const body = JSON.parse(text);
+ return (
+ body.errors?.[0]?.detail ?? body.response?.error ?? body.message ?? text
+ );
+ } catch {
+ return text;
+ }
+}
+
+async function request(path, options, label) {
+ let response;
+
+ try {
+ response = await fetch(`${API}${path}`, {
+ ...options,
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'x-api-key': API_KEY
+ },
+ signal: AbortSignal.timeout(TIMEOUT_MS)
+ });
+ } catch {
+ throw stopBatch(
+ `${label}: the network request failed. Check your connection and run again.`
+ );
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ throw stopBatch(
+ `${label}: the API rejected the key (${response.status}). Check SHOTSTACK_API_KEY and SHOTSTACK_ENV.`
+ );
+ }
+
+ if (!response.ok) {
+ throw new Error(`${label}: ${response.status} ${await apiError(response)}`);
+ }
+
+ return (await response.json()).response;
+}
+
+export async function submitRender(edit, label) {
+ const { id } = await request(
+ '/render',
+ { method: 'POST', body: JSON.stringify(edit) },
+ label
+ );
+
+ if (!id) {
+ throw new Error(`${label}: the render response did not contain an id.`);
+ }
+
+ return id;
+}
+
+export function getRender(renderId, label) {
+ return request(`/render/${renderId}`, { method: 'GET' }, label);
+}
diff --git a/examples/agency-client-social-videos/clients.json b/examples/agency-client-social-videos/clients.json
new file mode 100644
index 0000000..bfa9185
--- /dev/null
+++ b/examples/agency-client-social-videos/clients.json
@@ -0,0 +1,26 @@
+[
+ {
+ "slug": "tidewater-stays",
+ "name": "Tidewater Stays",
+ "brandColor": "#0f4c5c",
+ "brandMark": "",
+ "media": [
+ {
+ "type": "video",
+ "src": "https://shotstack-assets.s3.amazonaws.com/footage/beach-overhead.mp4"
+ },
+ {
+ "type": "image",
+ "src": "https://shotstack-assets.s3.amazonaws.com/images/slideshow1.jpeg"
+ },
+ {
+ "type": "video",
+ "src": "https://shotstack-assets.s3.amazonaws.com/footage/sunset.mp4"
+ },
+ {
+ "type": "image",
+ "src": "https://shotstack-assets.s3.amazonaws.com/images/slideshow5.jpeg"
+ }
+ ]
+ }
+]
diff --git a/examples/agency-client-social-videos/edit.mjs b/examples/agency-client-social-videos/edit.mjs
new file mode 100644
index 0000000..79e8334
--- /dev/null
+++ b/examples/agency-client-social-videos/edit.mjs
@@ -0,0 +1,75 @@
+const MUSIC_URL =
+ 'https://shotstack-assets.s3.amazonaws.com/music/unminus/happy.mp3';
+const SECONDS_PER_ITEM = 4;
+const END_CARD_SECONDS = 2;
+const WIDTH = 1080;
+const HEIGHT = 1920;
+
+// One clip per item in the client's media library, images and videos mixed.
+// "auto" starts each clip when the previous one ends, so a client with three
+// items gets a shorter video than a client with five. Video clips play
+// without their own sound, under the music.
+function mediaClips(client) {
+ return client.media.map((item, index) => ({
+ asset:
+ item.type === 'video'
+ ? { type: 'video', src: item.src, volume: 0 }
+ : { type: 'image', src: item.src },
+ start: index === 0 ? 0 : 'auto',
+ length: SECONDS_PER_ITEM,
+ fit: 'crop',
+ effect: item.type === 'image' ? 'zoomInSlow' : undefined
+ }));
+}
+
+export function buildEdit(client) {
+ const endStart = client.media.length * SECONDS_PER_ITEM;
+ const background = ``;
+
+ return {
+ timeline: {
+ background: '#000000',
+ tracks: [
+ {
+ clips: [
+ {
+ asset: { type: 'svg', src: client.brandMark },
+ start: endStart,
+ length: END_CARD_SECONDS,
+ width: 240,
+ height: 240,
+ fit: 'contain',
+ transition: { in: 'fade' }
+ }
+ ]
+ },
+ {
+ clips: [
+ ...mediaClips(client),
+ {
+ asset: { type: 'svg', src: background },
+ start: 'auto',
+ length: END_CARD_SECONDS,
+ transition: { in: 'fade' }
+ }
+ ]
+ },
+ {
+ clips: [
+ {
+ asset: {
+ type: 'audio',
+ src: MUSIC_URL,
+ volume: 0.5,
+ effect: 'fadeOut'
+ },
+ start: 0,
+ length: 'end'
+ }
+ ]
+ }
+ ]
+ },
+ output: { format: 'mp4', size: { width: WIDTH, height: HEIGHT } }
+ };
+}
diff --git a/examples/agency-client-social-videos/render.mjs b/examples/agency-client-social-videos/render.mjs
new file mode 100644
index 0000000..1d2a559
--- /dev/null
+++ b/examples/agency-client-social-videos/render.mjs
@@ -0,0 +1,84 @@
+import { readFile } from 'node:fs/promises';
+import { fail, requireConfig, submitRender } from './api.mjs';
+import { buildEdit } from './edit.mjs';
+import { recordRender } from './renders.mjs';
+
+const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
+const MEDIA_TYPES = ['image', 'video'];
+
+requireConfig();
+
+let clients;
+
+try {
+ clients = JSON.parse(
+ await readFile(new URL('./clients.json', import.meta.url), 'utf8')
+ );
+} catch (error) {
+ fail(`Could not read clients.json: ${error.message}`);
+}
+
+if (!Array.isArray(clients) || clients.length === 0) {
+ fail('clients.json must contain an array with at least one client.');
+}
+
+const problems = [];
+
+clients.forEach((client, index) => {
+ const label = client.slug ?? `client ${index + 1}`;
+
+ if (!client.slug || !client.name) {
+ problems.push(`${label}: slug and name are required.`);
+ }
+
+ if (!HEX_COLOR.test(client.brandColor ?? '')) {
+ problems.push(`${label}: brandColor must be a six-digit hex color.`);
+ }
+
+ if (!/^