---
title: Make a video sales letter with slides, b-roll or an avatar
description: Turn your sales script into a video sales letter (VSL) as text slides, stock b-roll or a talking avatar, with a voiceover and captions.
---
Turn your sales script into a video sales letter, or VSL: a long sales video that reads your offer out loud. You pick one of three styles. Slides show your words on screen, b-roll shows stock footage under the voice, and an avatar shows a presenter who speaks the script. Every style gets a voiceover, captions and an editable timeline.

Use invideo for this when you want to change one line of the script later and re-render that part, without rebuilding a long video.

You write or approve the script. Your agent never invents claims, prices or guarantees for your offer. The agent never publishes the video. You upload it to your video host or funnel builder.

## Quickest path

Set up your agent first with [invideo for AI agents](/agents). Then give it this prompt.

```text
Make a video sales letter from script.md in the b-roll style. Voice it
with a confident narrator, show me the voice preview first. Stock b-roll
for each section, captions at the bottom, 16:9. Price everything before
you spend, and show me frames before you export.
```

In a chat app, you paste the script or drag the file into the project. In a terminal, the agent names the file.

## What you need

These are the inputs the agent asks for.

- The script, final or close to final. Mark the sections: hook, problem, offer, proof, call to action.
- The style: slides, b-roll or avatar.
- For slides: your deck as a PDF, or permission for the agent to make text slides on the timeline.
- For an avatar: a front-facing photo of a presenter who agreed to it, or a generated presenter.
- A voice. The agent finds one with `search_library` and sends you the preview.
- The shape. A VSL is usually 16:9.

## Steps

The agent works on a branch, so the main timeline stays clean until you approve.

<Steps>
<Step title="Voice the script">
The agent runs `find_models` with the job in words, picks one, and reads its inputs with `get_model`. A good pick is `elevenlabs_text_to_speech.elevenlabs_tts_v4`, with a voice from `search_library` in `elevenlabs_text_to_speech_voices`, because a voice id works only with its own model. It sends you the voice preview. It calls `generate` with `dry_run: true`, one section as `prompt` and the `voice_id`, and tells you the price. Then it calls `generate` with `max_credits` set to that price and an `idempotency_key`, one take per section. Expected result: one sequence URL per section, which the agent reads with `read`.
</Step>
<Step title="Get word times for captions">
The agent prices `audio.transcript` on each voice take's sequence URL with `dry_run`, starts it with `max_credits` set to that price, and calls again after `retry_after_s` for the words. Expected result: every word with its start and end time.
</Step>
<Step title="Make the picture for slides or b-roll">
For slides, the agent uploads the PDF with `start_upload` and `add_media`, then runs `media` `document.pages` for one image per page; the dry run shows its price before anything starts. For b-roll, it searches `search_library` in `stock_video` with words from each section and brings the picks in with `add_media` and `stock`. Expected result: picture sequences for every section.
</Step>
<Step title="Make the avatar clips">
For the avatar style, the agent runs `find_models` with the job in words, picks one, and reads its inputs with `get_model`. A good pick is `heygen_photo_avatar`, and `kling_avatar.std` is another. It calls `generate` with the presenter photo as `image`, a voice take as `audio` and `dry_run: true`, and tells you the price. Then it calls `generate` with `max_credits` set to that price and an `idempotency_key`. Expected result: a thread URL and, when the clip lands, a sequence URL, which the agent reads with `read`.
</Step>
<Step title="Lay out the timeline">
The agent makes a 16:9 timeline with `new_timeline` and creates a branch with `timeline_history`. It places each voice take on an audio track with `insert_media`, and each slide, b-roll shot or avatar clip above it with `place_media`. It sets each picture's length to its line with `trim_clip`. Expected result: a new `state_token` and no gap under the voice.
</Step>
<Step title="Add captions and section titles">
The agent adds the words with `add_captions`, slides and words from the transcript, frames at the timeline rate. For the slides style, it adds key lines as text with `add_text`. Expected result: a caption clip on a video track above the picture.
</Step>
<Step title="Check, then export">
The agent runs `capture` at each section start and `check_timeline`, and reads the captions against your script. Then it runs `export` with `format` video. Expected result: an export card with status `completed`.
</Step>
</Steps>

The terminal version, b-roll style:

```bash
invideo project new --title "VSL"
invideo agent register <project-url> --name <name>
export INVIDEO_AS=<name>
invideo library search <project-url> \
  --library elevenlabs_text_to_speech_voices --query "confident narrator"
invideo gen models <project-url> --query "confident narration voice"
invideo gen model <project-url> \
  --model elevenlabs_text_to_speech.elevenlabs_tts_v4
invideo gen run <page-url> \
  --model elevenlabs_text_to_speech.elevenlabs_tts_v4 \
  --dry-run -i prompt=@hook.txt -i voice_id=<voice-id>
invideo gen run <page-url> \
  --model elevenlabs_text_to_speech.elevenlabs_tts_v4 \
  --max-credits <price> --idempotency-key vsl-hook-v1 \
  -i prompt=@hook.txt -i voice_id=<voice-id>
invideo media audio.transcript <sequence-url> --dry-run
invideo media audio.transcript <sequence-url> --max-credits <price>
invideo library search <project-url> --library stock_video \
  --query "tired office worker at desk"
invideo edit new <project-url> --name "VSL v1"
invideo asset add <edit-url> --stock <stock-id>
invideo edit show <edit-url> --json
invideo edit change <edit-url> --timeline T1 --token <state_token> \
  --operations @vsl-layout.json
invideo edit capture <edit-url> --at 2,60,240,600 --out ./frames
invideo edit export <edit-url> --format video --quality 1080 \
  --start 0 --end 600 --idempotency-key vsl-part-1 --json
invideo status <part-1-url> --wait
invideo media video.stitch <part-1-url> \
  --video-array <part-1-url>,<part-2-url> --dry-run
invideo media video.stitch <part-1-url> \
  --video-array <part-1-url>,<part-2-url> \
  --max-credits <price> --idempotency-key vsl-stitch-v1
invideo status <stitched-url> --wait
invideo notebook show <stitched-url> --out ./out
```

Where the values come from: `<project-url>` from `invideo project new`, and `<edit-url>` from `invideo edit new`. `<name>` is the name you choose in `invideo agent register`, and `<page-url>` is a notebook page from `invideo notebook show <project-url>`. `<voice-id>` comes from the voice search, and each `<price>` is the dry run right before it. `<sequence-url>` is the sequence URL from the voice run. `<stock-id>` comes from the stock search. `<state_token>` comes from `invideo edit show`. `<part-1-url>` and `<part-2-url>` are the card URLs, `.sequences[0]` of what each part's export returns. `<stitched-url>` is the sequence URL `video.stitch` returns. `invideo status` does not download; `invideo notebook show` saves the file as `export.mp4`.

The key edit is the ops file `vsl-layout.json`, a bare list. It places a voice take on `A1` (Voice), a stock shot on `V2` (B-roll), and a caption slide on `V3` (Titles). The media bin ids `m1` and `m2` come from `invideo edit show` with `--mode collection --collection asset`. At 30 fps, frame 300 is 10 seconds.

```json
[
  { "op": "insert_media", "asset": "m1", "track": "A1",
    "at_f": 0, "ripple": false },
  { "op": "place_media", "asset": "m2", "track": "V2", "at_f": 0,
    "source_start_seconds": 0, "source_end_seconds": 10,
    "mode": "overwrite", "linked_audio": false },
  { "op": "add_captions", "track": "V3",
    "slides": [
      { "text": "You work late every night", "start_f": 0,
        "end_f": 75 }
    ] }
]
```

## Cost and time

Edits and exports are free. Generation and transcripts cost credits. Paid stock costs a small credit charge, or one of your workspace's iStock licenses. Your agent runs a dry run first and tells you the price before it spends anything.

- Free: every timeline edit, `capture` and `export`.
- `document.pages` and `video.stitch`: the dry run shows the price before anything starts.
- Costs credits: the TTS voice, `audio.transcript`, each stock shot, and the avatar model, which charges by the length of the audio.

A short letter takes under an hour. A long avatar letter takes longer, most of it the avatar generation. See [Credits](/account/credits) for prices.

## If something goes wrong

Most problems are timing between the voice and the picture.

| Symptom | Fix |
| --- | --- |
| `get_model` or `generate` refuses the model as not found | Models retire. Run `find_models` with the job in words, use the best match, and tell the person which model you used. |
| Exit code 6, or `status` is `held` | A safety review, not a refusal. Wait, then run `invideo status <url> --wait` again. Do not resend. If it ends `failed`, change the input the review flagged. |
| The picture changes before the line ends | The picture clip is shorter than its voice take. Trim its tail to the take's end with `trim_clip`. |
| The avatar's mouth drifts from the voice | Generate the section again from one voice take. Do not stretch the avatar clip. |
| A long letter needs more than one export | Export in parts with `start` and `end` in seconds, one `idempotency_key` each, then join the parts with `media` `video.stitch`. Each export card is a sequence, so `video.stitch` takes their URLs as they are. |
| A caption word is wrong | Fix it with `set_caption_slide_text`. The transcript hears the voice, not the script. |
| `audio.transcript` returns a status, not words | It is still running. Call again with the same file after `retry_after_s`. |
| `timeout` on `generate` | Do not call it again. `read` the thread or call `status`. |
| CLI exit 3 | The edit changed since your read. Run `invideo edit show` and rebuild. |
| The download folder is empty | `invideo status` does not download. Run `invideo notebook show <card-url> --out ./out` on the card URL. |
| CLI exit 5 | Credits ran out. Stop and tell the person. |
| Avoid: one export of a very long letter that keeps failing | Instead: export in parts with `start` and `end`, then `video.stitch`. |
| Avoid: writing new claims into the script | Instead: ask the person. The script is theirs. |

## Prompts to try

Each prompt is a real variation of this job.

```text
Make a slide-style VSL from offer.pdf. One page per section, my script
below as the voiceover, captions on. Keep each slide up while its line
plays.
```

```text
Use this photo of me as a talking avatar for the hook and the call to
action, and stock b-roll for the middle. Same voice throughout.
```

```text
Change the price line in section 4 of my VSL to the new offer, voice
only that line again, and re-export.
```

## When to use something else

invideo does not host the video, gate it behind an email form, or show a buy button under it. Upload the MP4 to your video host or funnel builder for that. For a presenter avatar from a full video of yourself, use HeyGen or Synthesia, then bring the clip in with `start_upload`. If you need a script from scratch, write it with your own copywriter or agent first, then start here.

## Related

- [Turn slides into a narrated video, one deck or hundreds](/how-to/training/slides-to-narrated-video)
- [Voice a script or narrate a course](/how-to/training/voice-a-script-or-narrate-a-course)
- [Update one line and re-render, keep the rest](/how-to/training/update-one-line)
- [Label AI content so your ad runs on TikTok, Meta and Google](/how-to/marketing/ai-disclosure-for-ads)
- [Add word-by-word captions](/how-to/creators/word-by-word-captions)
- [Know the cost before you spend](/how-to/cost-before-you-spend)