Build a Gemini YouTube Content Agent

Build a Gemini CLI that turns cartoon ideas into validated content packs.

Introduction

30 Second Summary

A funny cartoon premise is easy to jot down. The hard part is carrying that same idea through every decision needed before production.

In this project, you will build a Linux command-line agent powered by Gemini. It transforms one original cartoon-comedy seed into a readable production brief plus structured JSON for human review.

What You'll Build

Your finished agent turns a lazy-lion story seed into a complete cartoon-comedy package ready for your approval.

By the end of this project, you'll have:

  • Five original angles generated from one story seed. The agent also recommends a winner based on its creative potential.
  • A complete long-video plan covering the hook, scenes, voice-over, visual prompts, animation prompts, editing notes, and call to action.
  • A production handoff containing three distinct Shorts, thumbnail direction, and an SEO package. A review checklist keeps originality, audience designation, advertiser friendliness, and AI disclosure under human control.
  • Secret Mission: Create a reusable brand brief. Test whether two unrelated ideas still sound like the same channel.

Are there any prerequisites?

You need a Linux computer plus a Google account. The guide covers Python setup, Google AI Studio, and Gemini API access.

Before We Start

Your agent needs a clear creative starting point before any hands-on work begins. This is your moment to commit to an original cartoon-comedy seed and the audience it should entertain.

Set Up the Gemini Python Workspace

Your cartoon-comedy seed has a clear audience now. The agent needs a dependable workspace before it can turn that seed into a draft.

A Python virtual environment keeps this project's packages separate from the system installation. A Gemini API key lets the client authenticate without saving the secret in a project file.

In this step, get ready to:
  • Confirm Python 3.10 or newer is available inside an activated .venv environment.
  • Install the pinned dependencies from requirements.txt.
  • Configure Gemini API authentication for the current shell.
Prepare the Python environment

Linux distributions can provide different Python versions. This project needs Python 3.10 or newer because both pinned packages require it.

  • Check the installed Python version by running this command:
python3 --version

What does this check prove?

This command reports the Python interpreter available through python3. The result determines whether you can create the project environment now.

✔️ I see version 3.10 or higher

Your Python version meets the project's minimum requirement. You can create the isolated environment next.

ⓧ I see an older version

The pinned package releases require Python 3.10 or newer. Ubuntu can install its complete default Python runtime with the command below.

Ubuntu may ask for your account password. The terminal hides the characters while you type.

  • Install the complete Ubuntu Python runtime and repeat the version check by running these commands:
sudo apt install -y python3-full
python3 --version

What do these commands do?

  • The first command installs Ubuntu's complete Python runtime with virtual-environment support.
  • The second command reports the version now available through python3.
  • Use your Linux distribution's official package instructions to install Python 3.10 or newer if you do not use Ubuntu.
  • Repeat the version check at the top of this substep.

ⓧ Command not found

Your shell cannot currently find Python. Ubuntu can install the complete runtime with virtual-environment support.

Ubuntu may ask for your account password. The terminal hides the characters while you type.

  • Install the complete Ubuntu Python runtime and check the version by running these commands:
sudo apt install -y python3-full
python3 --version

What do these commands do?

  • The first command installs Python with the standard library and virtual-environment support.
  • The second command confirms that your shell can now find python3.
  • Use your Linux distribution's official package instructions to install Python 3.10 or newer if you do not use Ubuntu.
  • Repeat the version check at the top of this substep.

The directory currently shown by your Linux terminal becomes this project's directory. The .venv folder keeps the project interpreter separate from system Python.

  • Create the virtual environment and activate it in the current shell by running these commands:
python3 -m venv .venv
source .venv/bin/activate

What do these commands do?

  • The first command creates an isolated Python environment inside .venv.
  • The second command activates that environment for the current shell.
  • Confirm that the activated environment provides the required Python version by running:
python --version

What should I see?

Your terminal should report Python 3.10 or newer. This confirms that future package installations go through the activated environment.

🙋‍♀️ Virtual environment not activating?

  • Confirm that the .venv folder was created in the directory used by your terminal.
  • Run the activation command from the same directory that contains .venv.
  • Install Ubuntu's complete Python runtime if the environment creation step reports missing virtual-environment support.

Ask for help to diagnose why my Linux Python virtual environment will not activate.

Install the pinned dependencies

The Google Gen AI Python SDK sends requests to Gemini. Pydantic checks later structured responses against the content contract.

Pinning google-genai to 2.33.0 keeps the SDK behavior consistent. Pinning pydantic to 2.14.1 keeps validation consistent.

  • Use your code editor's new-file control to create requirements.txt inside the same directory as .venv with this exact content:
google-genai==2.33.0
pydantic==2.14.1

What does this file control?

  • The first line pins the Gemini SDK to version 2.33.0.
  • The second line pins Pydantic to version 2.14.1.
  • These pins make the project use the same dependency versions as the finished code.

The first installation can take a minute while pip downloads the packages. A short pause during this command is normal.

  • Save requirements.txt.
  • Install the pinned dependencies into the activated environment by running:
python -m pip install -r requirements.txt

What does this command do?

This command asks the activated Python interpreter to run pip. Pip reads both pinned entries from requirements.txt and installs them inside .venv.

The installation output should finish successfully after downloading the SDK and its supporting packages.

Dependency installation failed?

  • Confirm that your virtual environment is still active in the current terminal.
  • Check that requirements.txt contains both package lines exactly as shown above.
  • Confirm that your internet connection can reach the Python package registry.

Ask for help to fix my pinned Python dependency installation.

✔️ Awesome, I've got everything!

Great. Your activated environment now contains both pinned dependencies.

ⓧ I'd like to double check the full code

Compare your complete requirements.txt file with this reference:

google-genai==2.33.0
pydantic==2.14.1

What should match?

Your file should contain these two lines in this order. Each version number should match exactly.

Configure Gemini authentication

Google AI Studio creates the API key that authorizes your Gemini requests. You will keep that key in the current terminal session.

You should now see Google AI Studio under your signed-in account. This confirms that you can access the key creation flow.

The key flow may ask you to choose a project. Complete that prompt with the Google account you just used.

  • Open the API keys page inside Google AI Studio.
  • Click Create API key.
  • Complete any project selection prompt that appears.
  • Copy the generated key to your clipboard.

Your key is ready to authenticate requests. Keep the revealed value private because it grants access to your Gemini API allowance.

This is the only sensitive value in the setup. The export command keeps it out of the project files and does not print it as output.

  • Return to the activated terminal from earlier.
  • Replace YOUR_API_KEY with the copied key and export it by running this command:
export GEMINI_API_KEY="YOUR_API_KEY"

What does this command do?

The command stores the key under GEMINI_API_KEY for the current shell. The Gemini client reads that environment variable when it starts.

The key stays out of requirements.txt and future Python files. Closing the terminal removes the exported value from that shell session.

Before you run the final check, which two success signals do you expect from the Python version and import test?

  • Verify the active Python environment and both installed imports by running:
python --version && python -c "from google import genai; import pydantic; print('Setup OK')"

What should I see?

The first line reports Python 3.10 or newer. The final line reads Setup OK.

🙋‍♀️ Workspace check failed?

  • Confirm that the virtual environment is still active in the terminal running the check.
  • Compare requirements.txt with the full-file reference above.
  • Repeat the dependency installation from the same project directory if an import is missing.

Ask for help to diagnose why my Python workspace check is failing.

That is the foundation in place. Your isolated Python workspace can now import the Gemini SDK and Pydantic successfully.

Your workspace is authenticated and ready. Next, you will send your cartoon-comedy seed to Gemini and inspect the first free-form draft.

Preview an Unstructured Draft

Your activated Python workspace can now authenticate with the Gemini API. The next question is whether one detailed prompt can turn your cartoon-comedy seed into a useful production draft.

This step builds the smallest usable command-line agent around gemini-3.8-flash. You will send one creative prompt before inspecting the response that comes back.

In this step, get ready to:
  • Create youtube_agent.py as a command-line entry point.
  • Send a guarded creative prompt to Gemini.
  • Run the free-form preview to test its consistency.
Create the command-line entry point

A command-line interface lets your script receive a story seed when it starts. This first version also checks that your current shell still contains the API key from the previous step.

  • Create youtube_agent.py beside requirements.txt by using the new-file control in your code editor.
  • Add the imports and model identifier by copying this code into youtube_agent.py:
import argparse
import os

from google import genai


MODEL_ID = "gemini-3.8-flash"

What does this foundation do?

  • argparse prepares the script to receive the idea and optional settings from the terminal.
  • os gives the script access to environment variables such as GEMINI_API_KEY.
  • from google import genai loads the installed Google Gen AI Python SDK.
  • MODEL_ID keeps the model identifier in one place for every request.
  • Save youtube_agent.py.
  • Confirm that your editor lists youtube_agent.py beside requirements.txt.

File missing from your project?

Check that your editor created youtube_agent.py in the same project directory as requirements.txt. Move the file there if it was created in another folder.

help me place youtube_agent.py in the correct project directory.

The entry point defines what the script accepts. The --preview flag selects the early free-form path that you will test in this step.

  • Add the command-line entry point below MODEL_ID by copying this code:
def main() -> None:
    parser = argparse.ArgumentParser(
        description="Generate a structured YouTube cartoon-comedy content pack."
    )
    parser.add_argument("idea", help="Original cartoon-comedy story seed")
    parser.add_argument("--language", default="English")
    parser.add_argument(
        "--preview",
        action="store_true",
        help="Print a free-form draft to expose the unstructured-output problem.",
    )
    args = parser.parse_args()

    if not os.environ.get("GEMINI_API_KEY"):
        raise SystemExit("Set GEMINI_API_KEY before running the agent.")

    if args.preview:
        print(generate_preview(args.idea, args.language))
        return

    raise SystemExit("Run this early version with --preview.")


if __name__ == "__main__":
    main()

How does the entry point work?

  • parser.add_argument("idea", ...) makes the story seed a required input.
  • --language accepts an optional output language with English as its default value.
  • --preview switches on the free-form generation path.
  • os.environ.get("GEMINI_API_KEY") stops the script safely when the current shell cannot provide the API key.
  • main() starts the command-line flow when Python runs this file directly.
  • Save youtube_agent.py.

Before you run this check, what do you think the early CLI says when you provide an idea without --preview?

  • Check the command-line entry point by running:
python youtube_agent.py "A lazy lion opens a gym"

You should see Run this early version with --preview. in the terminal. Your script has accepted the story seed before directing you to its only generation path.

Command-line check failed?

Confirm that your terminal is still inside the project directory containing youtube_agent.py. Check that the virtual environment remains active in this shell.

If the script reports a missing API key, return to the shell where you exported GEMINI_API_KEY.

help me debug the early youtube_agent.py command-line check.

Connect the free-form preview

Prompt guardrails tell the model what creative boundaries and deliverables matter. Your prompt requests original comedy while preserving the human review decisions that belong outside the model.

  • Insert build_prompt() between MODEL_ID and main() by copying this code:
def build_prompt(idea: str, language: str) -> str:
    return f"""
Create an original YouTube production package from this seed:

{idea}

Output language: {language}
Content style: family-friendly cartoon comedy for a general audience.

Requirements:
- Generate exactly five materially different story ideas.
- Select one idea using clarity, comedy, emotion, and visual potential.
- Create one cohesive long-video plan with a beginning, middle, and end.
- Create exactly three Shorts with different hooks, conflicts, and payoffs.
- Include voice-over, image prompts, animation prompts, editing notes,
  thumbnail direction, titles, description, hashtags, keywords, and tags.
- Keep every character, joke, image, and storyline original.
- Do not imitate a living creator, copyrighted character, franchise, or brand.
- Avoid profanity, sexual material, graphic violence, dangerous acts,
  discrimination, humiliation, shock bait, and manipulative distress.
- Do not claim an idea is trending unless the input supplies a source.
  This tool does not browse the web.
- Explain that family-friendly does not automatically determine whether a
  video is made for kids. Flag audience designation for human review.
- Flag realistic synthetic depictions, real-person impersonation, real-event
  alteration, realistic fake scenes, or AI-generated music for disclosure
  review. Do not make a legal conclusion.
- Require human approval before production or publication.
- Make the ideas distinct enough that they do not feel mass-produced.
""".strip()

What does the prompt control?

  • idea inserts the learner's story seed into the creative brief.
  • language tells the model which language to use for the response.
  • The requirements ask for five story ideas and three Shorts.
  • The originality rules keep the request focused on new characters and storylines.
  • The review rules preserve human decisions about audience designation and AI disclosure.
  • Save youtube_agent.py.
  • Confirm that build_prompt() sits between MODEL_ID and main() in the editor.

Prompt showing syntax warnings?

Check that the prompt begins with return f""" and ends with """.strip(). Keep the prompt lines inside those matching triple quotes.

help me check the build_prompt function for a Python syntax problem.

The request function creates a Gemini client for one generation call. Its cleanup path closes the client even when the request fails.

  • Insert generate_preview() below build_prompt() by copying this code:
def generate_preview(idea: str, language: str) -> str:
    client = genai.Client()
    try:
        response = client.models.generate_content(
            model=MODEL_ID,
            contents=build_prompt(idea, language),
        )
        return response.text or "No preview text was returned."
    finally:
        client.close()

How does the request work?

  • genai.Client() creates an authenticated client using the configured Gemini API environment variable.
  • client.models.generate_content() sends the selected model your completed creative prompt.
  • response.text holds the free-form text returned by the model.
  • client.close() releases the synchronous client after the request finishes.
  • Save youtube_agent.py.
  • Confirm that generate_preview() appears above main() in the editor.

Request function showing an error?

Check the indentation inside try and finally. Confirm that the function name matches the generate_preview() call inside main().

help me fix the generate_preview function in youtube_agent.py.

✔️ Awesome, I've got everything!

Your free-form preview path is complete. Make sure youtube_agent.py is saved before you run it.

ⓧ I'd like to double check the full code

Compare your saved youtube_agent.py with this complete early version:

import argparse
import os

from google import genai


MODEL_ID = "gemini-3.8-flash"


def build_prompt(idea: str, language: str) -> str:
    return f"""
Create an original YouTube production package from this seed:

{idea}

Output language: {language}
Content style: family-friendly cartoon comedy for a general audience.

Requirements:
- Generate exactly five materially different story ideas.
- Select one idea using clarity, comedy, emotion, and visual potential.
- Create one cohesive long-video plan with a beginning, middle, and end.
- Create exactly three Shorts with different hooks, conflicts, and payoffs.
- Include voice-over, image prompts, animation prompts, editing notes,
  thumbnail direction, titles, description, hashtags, keywords, and tags.
- Keep every character, joke, image, and storyline original.
- Do not imitate a living creator, copyrighted character, franchise, or brand.
- Avoid profanity, sexual material, graphic violence, dangerous acts,
  discrimination, humiliation, shock bait, and manipulative distress.
- Do not claim an idea is trending unless the input supplies a source.
  This tool does not browse the web.
- Explain that family-friendly does not automatically determine whether a
  video is made for kids. Flag audience designation for human review.
- Flag realistic synthetic depictions, real-person impersonation, real-event
  alteration, realistic fake scenes, or AI-generated music for disclosure
  review. Do not make a legal conclusion.
- Require human approval before production or publication.
- Make the ideas distinct enough that they do not feel mass-produced.
""".strip()


def generate_preview(idea: str, language: str) -> str:
    client = genai.Client()
    try:
        response = client.models.generate_content(
            model=MODEL_ID,
            contents=build_prompt(idea, language),
        )
        return response.text or "No preview text was returned."
    finally:
        client.close()


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Generate a structured YouTube cartoon-comedy content pack."
    )
    parser.add_argument("idea", help="Original cartoon-comedy story seed")
    parser.add_argument("--language", default="English")
    parser.add_argument(
        "--preview",
        action="store_true",
        help="Print a free-form draft to expose the unstructured-output problem.",
    )
    args = parser.parse_args()

    if not os.environ.get("GEMINI_API_KEY"):
        raise SystemExit("Set GEMINI_API_KEY before running the agent.")

    if args.preview:
        print(generate_preview(args.idea, args.language))
        return

    raise SystemExit("Run this early version with --preview.")


if __name__ == "__main__":
    main()
Inspect the unstructured result

The preview path now sends a real request using your exported API key. The terminal prints the response exactly as Gemini returns it.

  • Replace A lazy lion opens a gym in the command below with your original cartoon-comedy story seed.

Before you run the preview, what shape do you expect from a prompt that requests exact counts but accepts plain text?

  • Generate your free-form draft by running:
python youtube_agent.py "A lazy lion opens a gym" --preview

You should see a cartoon-comedy draft printed as one free-form block in the terminal. Its wording and layout can differ between requests.

Your first live agent result is on screen. The prompt now reaches Gemini. It also returns creative material for your story seed.

Why can't this draft drive production?

Plain text gives Gemini freedom to choose its own headings and layout. The prompt requests specific sections without creating machine-enforced fields.

Even when this response happens to contain five ideas and three Shorts, your code has no stable keys to inspect. A structured output contract provides those dependable locations.

Required review notes can also appear under changing labels or disappear into paragraphs. A repeatable workflow needs validation before any package reaches production.

  • Count the story ideas in your terminal output.
  • Count the Shorts in your terminal output.
  • Check whether the draft clearly labels originality review.
  • Check whether the draft clearly labels audience designation review.
  • Check whether the draft clearly labels advertiser-friendliness review.
  • Check whether the draft clearly labels AI-disclosure review.

Your exact wording will vary. The terminal still holds one prose block.

Code cannot dependably extract exactly five ideas, three Shorts, or every required review field from that changing layout.

Preview request failed?

Confirm that the same terminal still has the activated virtual environment from the previous step. Return to the shell where you exported GEMINI_API_KEY if authentication is missing.

Check that your Linux computer has an active network connection because this preview calls the Gemini API.

help me debug the Gemini preview without exposing my API key.

You now have a live Gemini agent with a visible limitation. Next, you will replace the changing prose shape with a validated content contract.

Build the Validated Content-Pack Agent

Your preview turned one cartoon-comedy seed into a visible draft. It also proved that free-form prose cannot guarantee the sections or item counts your production workflow needs.

A strict contract closes that gap. In this step, Pydantic defines the contract while the Gemini API returns structured JSON for validation.

In this step, get ready to:
  • Define the models for every creative deliverable and review field.
  • Request structured JSON using the content-pack schema.
  • Reject packages that fail the required item counts or human-approval gate.
Define the content contract

A content contract describes every field that a valid package must contain. Each model covers one part of the workflow so missing or malformed data becomes a validation failure.

  • In youtube_agent.py, replace the current import section with this expanded import group:
import argparse
import json
import os

from google import genai
from google.genai import types
from pydantic import BaseModel

What Do These Imports Add?

  • The json module converts the validated response into a Python dictionary.
  • The types import provides the structured-output configuration.
  • The BaseModel import provides the foundation for every content contract.
  • Save youtube_agent.py.
  • Check the updated imports by running this syntax command:
python -m py_compile youtube_agent.py

What Does This Check Prove?

This command compiles the Python source without running the agent. A clean return to the terminal prompt confirms that the new imports use valid Python syntax.

🙋‍♀️ Seeing an import syntax error?

Check that each import appears once at the top of youtube_agent.py. Keep MODEL_ID below the complete import group.

Ask for help with the import section.

The first group describes an idea and the scenes that form a long video. These nested models let the final package validate each creative layer separately.

  • Below MODEL_ID = "gemini-3.8-flash", add the first three models:
class VideoIdea(BaseModel):
    title: str
    premise: str
    hook: str
    emotional_turn: str
    originality_angle: str


class Scene(BaseModel):
    beat: str
    voiceover: str
    visual_prompt: str
    animation_prompt: str
    edit_notes: str


class LongVideo(BaseModel):
    title: str
    hook: str
    scenes: list[Scene]
    call_to_action: str

How Does the Story Contract Work?

  • VideoIdea requires the creative premise and its originality angle.
  • Scene keeps voice-over and production directions attached to the same story beat.
  • LongVideo collects those scenes into one plan with a hook and call to action.
  • Save youtube_agent.py.
  • Confirm the first model group compiles by running:
python -m py_compile youtube_agent.py

What Should Happen?

A clean return to the prompt confirms that Python recognizes the three classes. It also confirms that LongVideo can reference the earlier Scene model.

🙋‍♀️ Does the model group fail to compile?

Check that every field is indented with four spaces beneath its class. Confirm that Scene appears before LongVideo.

Ask for help with the first model group.

The next models cover the short-form versions and the assets that help someone package the selected story. Every field stays explicit so downstream code can find it by name.

  • Below the LongVideo model, add the models for Shorts, thumbnails, and SEO:
class ShortVideo(BaseModel):
    title: str
    hook: str
    voiceover: str
    visual_prompt: str
    animation_prompt: str
    edit_plan: str


class ThumbnailPlan(BaseModel):
    concept: str
    text: str
    image_prompt: str


class SeoPackage(BaseModel):
    title_options: list[str]
    description: str
    hashtags: list[str]
    keywords: list[str]
    tags: list[str]

What Does This Model Group Cover?

  • ShortVideo gives each Short its own hook and payoff plan.
  • ThumbnailPlan keeps the thumbnail concept and image direction together.
  • SeoPackage stores the metadata that supports the upload review stage.
  • Save youtube_agent.py.
  • Confirm the production-asset models compile by running:
python -m py_compile youtube_agent.py

What Does a Clean Check Mean?

A clean return confirms that the new models use valid field types. Your script can now represent the short-form, thumbnail, and metadata sections.

🙋‍♀️ Seeing a type or indentation problem?

Keep every list field in the list[str] form. Check that each class starts at the far-left edge of the file.

Ask for help with the production-asset models.

The final contract layer keeps policy review visible and combines every smaller model. This makes human approval part of the data that the agent must return.

  • Below the SeoPackage model, add the review and top-level package models:
class ReviewChecklist(BaseModel):
    family_friendly: bool
    advertiser_friendly_notes: str
    originality_notes: str
    audience_designation_review: str
    ai_disclosure_review: str
    human_approval_required: bool


class ContentPack(BaseModel):
    channel_strategy: str
    ideas: list[VideoIdea]
    selected_idea: VideoIdea
    selection_reason: str
    long_video: LongVideo
    shorts: list[ShortVideo]
    thumbnail: ThumbnailPlan
    seo: SeoPackage
    review: ReviewChecklist

Why Is the Review Inside the Contract?

ReviewChecklist requires notes for originality, audience designation, advertiser friendliness, and AI disclosure. ContentPack then makes that checklist part of every complete response.

This structure keeps creative output connected to the decisions a person must review before production or publication.

  • Save youtube_agent.py.
  • Confirm the complete model contract compiles by running:
python -m py_compile youtube_agent.py

What Have You Proved?

A clean return confirms that every nested model resolves correctly. The script now has one top-level contract for the complete production package.

🙋‍♀️ Does ContentPack fail to compile?

Check that every referenced model appears above ContentPack. Confirm that the review field uses ReviewChecklist.

Ask for help with the nested contract.

Request and validate structured output

The contract becomes useful when Gemini receives its JSON schema with the creative prompt. The returned text can then pass through Pydantic before your script trusts it.

What Can Structured Output Guarantee?

Structured output constrains the response to schema-shaped JSON. Application checks still need to enforce exact counts and approval rules because valid structure does not guarantee valid meaning.

  • Below generate_preview(), add the structured generation function:
def generate_content_pack(
    idea: str, language: str
) -> tuple[ContentPack, dict]:
    client = genai.Client()
    try:
        response = client.models.generate_content(
            model=MODEL_ID,
            contents=build_prompt(idea, language),
            config=types.GenerateContentConfig(
                response_mime_type="application/json",
                response_json_schema=ContentPack.model_json_schema(),
            ),
        )
    finally:
        client.close()

    if not response.text:
        raise ValueError("Gemini returned an empty response.")

    content_pack = ContentPack.model_validate_json(response.text)

    if len(content_pack.ideas) != 5:
        raise ValueError("The package must contain exactly five ideas.")
    if len(content_pack.shorts) != 3:
        raise ValueError("The package must contain exactly three Shorts.")
    if not content_pack.review.human_approval_required:
        raise ValueError("Human approval must remain required.")

    return content_pack, json.loads(response.text)

How Does Validation Work?

  • ContentPack.model_json_schema() supplies Gemini with the required JSON shape.
  • ContentPack.model_validate_json() converts the response into validated Pydantic objects.
  • The three explicit checks enforce five ideas, three Shorts, and mandatory human approval.
  • The returned dictionary preserves the original JSON for the export stage.
  • Save youtube_agent.py.
  • Confirm the structured generation function compiles by running:
python -m py_compile youtube_agent.py

What Does This Check Confirm?

A clean return confirms that the structured-output configuration and validation logic use valid Python syntax. It also confirms that every referenced model is available to the function.

🙋‍♀️ Does the generation function fail to compile?

Check the closing parentheses around types.GenerateContentConfig() and client.models.generate_content(). Confirm that the three validation checks remain inside generate_content_pack().

Ask for help with the structured-output function.

Enforce the validated path

The command-line flow still stops after the preview branch. Replacing that temporary stop makes the normal command request a complete package and report only after validation succeeds.

  • In main(), find the temporary line below the preview branch:
    raise SystemExit("Run this early version with --preview.")

What Does the Current Line Do?

This line stops every normal run before structured generation begins. The preview path above it remains available for comparing free-form output.

  • Replace the temporary line with the validated generation and reporting flow:
    try:
        pack, _ = generate_content_pack(args.idea, args.language)
    except Exception as error:
        raise SystemExit(f"Generation failed: {error}") from error

    print(f"Validated {len(pack.ideas)} ideas and {len(pack.shorts)} Shorts.")
    print(f"Selected idea: {pack.selected_idea.title}")
    print("Review every field before production or publication.")

What Does the New Main Path Do?

The try block requests a validated package. Any API or validation failure exits with the underlying reason.

The success messages use the validated object to report the actual idea and Short counts. The final reminder keeps human review on the publishing path.

  • Save youtube_agent.py.

✔️ Awesome, I've got everything!

Your models, structured generation function, and validated command-line path are now in place. Make sure youtube_agent.py is saved before running it.

ⓧ I'd like to double check the full code

The complete cumulative youtube_agent.py file is below for comparison.

import argparse
import json
import os

from google import genai
from google.genai import types
from pydantic import BaseModel


MODEL_ID = "gemini-3.8-flash"


class VideoIdea(BaseModel):
    title: str
    premise: str
    hook: str
    emotional_turn: str
    originality_angle: str


class Scene(BaseModel):
    beat: str
    voiceover: str
    visual_prompt: str
    animation_prompt: str
    edit_notes: str


class LongVideo(BaseModel):
    title: str
    hook: str
    scenes: list[Scene]
    call_to_action: str


class ShortVideo(BaseModel):
    title: str
    hook: str
    voiceover: str
    visual_prompt: str
    animation_prompt: str
    edit_plan: str


class ThumbnailPlan(BaseModel):
    concept: str
    text: str
    image_prompt: str


class SeoPackage(BaseModel):
    title_options: list[str]
    description: str
    hashtags: list[str]
    keywords: list[str]
    tags: list[str]


class ReviewChecklist(BaseModel):
    family_friendly: bool
    advertiser_friendly_notes: str
    originality_notes: str
    audience_designation_review: str
    ai_disclosure_review: str
    human_approval_required: bool


class ContentPack(BaseModel):
    channel_strategy: str
    ideas: list[VideoIdea]
    selected_idea: VideoIdea
    selection_reason: str
    long_video: LongVideo
    shorts: list[ShortVideo]
    thumbnail: ThumbnailPlan
    seo: SeoPackage
    review: ReviewChecklist


def build_prompt(idea: str, language: str) -> str:
    return f"""
Create an original YouTube production package from this seed:

{idea}

Output language: {language}
Content style: family-friendly cartoon comedy for a general audience.

Requirements:
- Generate exactly five materially different story ideas.
- Select one idea using clarity, comedy, emotion, and visual potential.
- Create one cohesive long-video plan with a beginning, middle, and end.
- Create exactly three Shorts with different hooks, conflicts, and payoffs.
- Include voice-over, image prompts, animation prompts, editing notes,
  thumbnail direction, titles, description, hashtags, keywords, and tags.
- Keep every character, joke, image, and storyline original.
- Do not imitate a living creator, copyrighted character, franchise, or brand.
- Avoid profanity, sexual material, graphic violence, dangerous acts,
  discrimination, humiliation, shock bait, and manipulative distress.
- Do not claim an idea is trending unless the input supplies a source.
  This tool does not browse the web.
- Explain that family-friendly does not automatically determine whether a
  video is made for kids. Flag audience designation for human review.
- Flag realistic synthetic depictions, real-person impersonation, real-event
  alteration, realistic fake scenes, or AI-generated music for disclosure
  review. Do not make a legal conclusion.
- Require human approval before production or publication.
- Make the ideas distinct enough that they do not feel mass-produced.
""".strip()


def generate_preview(idea: str, language: str) -> str:
    client = genai.Client()
    try:
        response = client.models.generate_content(
            model=MODEL_ID,
            contents=build_prompt(idea, language),
        )
        return response.text or "No preview text was returned."
    finally:
        client.close()


def generate_content_pack(
    idea: str, language: str
) -> tuple[ContentPack, dict]:
    client = genai.Client()
    try:
        response = client.models.generate_content(
            model=MODEL_ID,
            contents=build_prompt(idea, language),
            config=types.GenerateContentConfig(
                response_mime_type="application/json",
                response_json_schema=ContentPack.model_json_schema(),
            ),
        )
    finally:
        client.close()

    if not response.text:
        raise ValueError("Gemini returned an empty response.")

    content_pack = ContentPack.model_validate_json(response.text)

    if len(content_pack.ideas) != 5:
        raise ValueError("The package must contain exactly five ideas.")
    if len(content_pack.shorts) != 3:
        raise ValueError("The package must contain exactly three Shorts.")
    if not content_pack.review.human_approval_required:
        raise ValueError("Human approval must remain required.")

    return content_pack, json.loads(response.text)


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Generate a structured YouTube cartoon-comedy content pack."
    )
    parser.add_argument("idea", help="Original cartoon-comedy story seed")
    parser.add_argument("--language", default="English")
    parser.add_argument(
        "--preview",
        action="store_true",
        help="Print a free-form draft to expose the unstructured-output problem.",
    )
    args = parser.parse_args()

    if not os.environ.get("GEMINI_API_KEY"):
        raise SystemExit("Set GEMINI_API_KEY before running the agent.")

    if args.preview:
        print(generate_preview(args.idea, args.language))
        return

    try:
        pack, _ = generate_content_pack(args.idea, args.language)
    except Exception as error:
        raise SystemExit(f"Generation failed: {error}") from error

    print(f"Validated {len(pack.ideas)} ideas and {len(pack.shorts)} Shorts.")
    print(f"Selected idea: {pack.selected_idea.title}")
    print("Review every field before production or publication.")


if __name__ == "__main__":
    main()

What Should Match?

Your cumulative file should contain the original preview path plus the new models and validated generation path. Export functions and the outputs directory are intentionally absent at this stage.

Before you run the agent, do you think the normal path can now confirm the exact number of ideas and Shorts?

  • Run the validated agent with the project idea by entering:
python youtube_agent.py "A lazy lion opens a gym"

What Should You See?

You'll see Validated 5 ideas and 3 Shorts. followed by a selected idea title. The final line reminds you to review every field before production or publication.

That is the contract doing its job. Your agent now accepts a package only after its structure, item counts, and human-approval gate pass validation.

Did generation fail?

  • Confirm that the current shell still has GEMINI_API_KEY available.
  • Compare each model field against the full-file reference.
  • Run the verification command once more if Gemini returned a package with the wrong item count.

Ask for help with the validation failure.

Your command-line agent now turns a story seed into a validated content object. Next, you'll export that object as readable Markdown and machine-readable JSON.

Export a Production-Ready Package

Your Pydantic contract now turns each Gemini response into validated JSON. The terminal confirms that each package contains the required ideas and Shorts.

Machine consistency solves one part of the workflow. A creator also needs a readable Markdown brief for writing, voice-over, artwork, animation, editing, thumbnails, and upload review.

In this step, get ready to:
  • Turn the validated content pack into a readable production brief.
  • Export matching Markdown and JSON files under outputs/.
  • Generate a second package in Hindi and inspect every review section.
Render the readable production brief

The validated object already holds every creative field. The renderer arranges those fields into a document that a human can scan before production.

  • In youtube_agent.py, find the import group at the top of the file.
  • Locate these lines:
import argparse
import json
import os

What are these imports for?

The existing imports support command-line arguments, JSON parsing, and environment-variable access. The export workflow needs two more standard-library tools.

  • Replace the existing import group with this expanded version:
import argparse
import json
import os
import re
from pathlib import Path

What changed?

  • The re module turns a generated title into a safe filename.
  • The Path class creates the output directory and writes each file.
  • Find the end of generate_content_pack().
  • Add the filename helper directly below return content_pack, json.loads(response.text) by pasting this code:
def slugify(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
    return slug[:60] or "youtube-content-pack"

How does the filename helper work?

  • The regular expression replaces groups of unsupported filename characters with a hyphen.
  • The result is limited to 60 characters.
  • The fallback keeps the export usable when a title contains no supported characters.

The next function builds one list of Markdown lines. You will add it in consecutive sections so each part maps to a section of the final production brief.

  • Add the start of render_markdown() directly below slugify() by pasting this code:
def render_markdown(pack: ContentPack) -> str:
    lines = [
        f"# {pack.selected_idea.title}",
        "",
        "## Channel Strategy",
        "",
        pack.channel_strategy,
        "",
        "## Five Original Ideas",
        "",
    ]

    for number, idea in enumerate(pack.ideas, start=1):
        lines.extend(
            [
                f"### {number}. {idea.title}",
                "",
                f"Premise: {idea.premise}",
                "",
                f"Hook: {idea.hook}",
                "",
                f"Emotional turn: {idea.emotional_turn}",
                "",
                f"Originality angle: {idea.originality_angle}",
                "",
            ]
        )

What does this section render?

  • The first list creates the document title and channel strategy section.
  • The loop gives each of the five ideas its own heading and creative details.
  • Blank strings become spacing between Markdown sections.
  • Continue render_markdown() directly below the ideas loop by adding this code:
    lines.extend(
        [
            "## Selected Idea",
            "",
            f"Selected: {pack.selected_idea.title}",
            "",
            pack.selection_reason,
            "",
            "## Long Video",
            "",
            f"Title: {pack.long_video.title}",
            "",
            f"Hook: {pack.long_video.hook}",
            "",
        ]
    )

What does this section render?

This section records the recommended idea and its selection reason. It also starts the long-video plan with its title and hook.

  • Continue the same function by adding the scene renderer below the long-video heading code:
    for number, scene in enumerate(pack.long_video.scenes, start=1):
        lines.extend(
            [
                f"### Scene {number}: {scene.beat}",
                "",
                f"Voice-over: {scene.voiceover}",
                "",
                f"Visual prompt: {scene.visual_prompt}",
                "",
                f"Animation prompt: {scene.animation_prompt}",
                "",
                f"Edit notes: {scene.edit_notes}",
                "",
            ]
        )

    lines.extend(
        [
            f"Call to action: {pack.long_video.call_to_action}",
            "",
            "## Shorts",
            "",
        ]
    )

What does this section render?

  • The scene loop preserves each story beat with its production instructions.
  • The final lines add the long video's call to action.
  • The Shorts heading prepares the next part of the brief.
  • Continue the function by adding the Shorts renderer below the ## Shorts section:
    for number, short in enumerate(pack.shorts, start=1):
        lines.extend(
            [
                f"### Short {number}: {short.title}",
                "",
                f"Hook: {short.hook}",
                "",
                f"Voice-over: {short.voiceover}",
                "",
                f"Visual prompt: {short.visual_prompt}",
                "",
                f"Animation prompt: {short.animation_prompt}",
                "",
                f"Edit plan: {short.edit_plan}",
                "",
            ]
        )

What does this section render?

Each Short receives a numbered section with its own hook, voice-over, visual direction, animation direction, and edit plan. The separate sections make differences between the three Shorts easy to review.

  • Continue the function by adding the thumbnail and SEO headings below the Shorts loop:
    lines.extend(
        [
            "## Thumbnail",
            "",
            f"Concept: {pack.thumbnail.concept}",
            "",
            f"Text: {pack.thumbnail.text}",
            "",
            f"Image prompt: {pack.thumbnail.image_prompt}",
            "",
            "## SEO Package",
            "",
            "### Title Options",
            "",
        ]
    )
    lines.extend(f"- {item}" for item in pack.seo.title_options)

What does this section render?

This section gives the thumbnail its own production direction. It also starts the SEO package with a Markdown list of title options.

  • Finish the production sections by adding the remaining SEO fields and human-review checklist:
    lines.extend(
        [
            "",
            "### Description",
            "",
            pack.seo.description,
            "",
            "### Hashtags",
            "",
            " ".join(pack.seo.hashtags),
            "",
            "### Keywords",
            "",
            ", ".join(pack.seo.keywords),
            "",
            "### Tags",
            "",
            ", ".join(pack.seo.tags),
            "",
            "## Human Review Checklist",
            "",
            f"- Family-friendly draft: {pack.review.family_friendly}",
            f"- Advertiser-friendly notes: {pack.review.advertiser_friendly_notes}",
            f"- Originality notes: {pack.review.originality_notes}",
            f"- Audience designation review: {pack.review.audience_designation_review}",
            f"- AI disclosure review: {pack.review.ai_disclosure_review}",
            f"- Human approval required: {pack.review.human_approval_required}",
            "",
        ]
    )

Why end with human review?

Structured output keeps the document complete. The review checklist keeps originality, audience designation, advertiser friendliness, and AI disclosure under human control.

  • Complete render_markdown() by adding this return line at the bottom of the function:
    return "\n".join(lines)

What does the return line do?

The renderer joins every collected line with newline characters. The result is one complete Markdown document ready to write to a file.

  • Save youtube_agent.py.

Seeing indentation warnings?

Keep every new line inside render_markdown() indented with four spaces. Keep lines inside each loop indented with eight spaces.

Make sure the final return aligns with the two top-level lines.extend() calls.

help me check the indentation in render_markdown()

Write the JSON and Markdown files

The renderer now produces readable text in memory. The export function creates an outputs directory and writes the two matching files to disk.

  • Add write_outputs() directly below render_markdown() by pasting this code:
def write_outputs(pack: ContentPack, json_data: dict) -> tuple[Path, Path]:
    output_dir = Path("outputs")
    output_dir.mkdir(exist_ok=True)
    base_name = slugify(pack.selected_idea.title)
    json_path = output_dir / f"{base_name}.json"
    markdown_path = output_dir / f"{base_name}.md"

    json_path.write_text(
        json.dumps(json_data, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    markdown_path.write_text(render_markdown(pack), encoding="utf-8")
    return json_path, markdown_path

What does this code do?

  • The output directory is created when it does not already exist.
  • The selected title becomes the shared base name for both files.
  • The JSON file preserves the validated response with readable indentation.
  • The Markdown file stores the production brief returned by render_markdown().
  • Save youtube_agent.py.
  • Find the try: block inside main().
  • Locate these lines:
    try:
        pack, _ = generate_content_pack(args.idea, args.language)
    except Exception as error:
        raise SystemExit(f"Generation failed: {error}") from error

What does the current block do?

The current block validates the response but discards its parsed JSON dictionary. The export function needs that dictionary to preserve the response data.

  • Replace the current try: block with this version:
    try:
        pack, json_data = generate_content_pack(args.idea, args.language)
        json_path, markdown_path = write_outputs(pack, json_data)
    except Exception as error:
        raise SystemExit(f"Generation failed: {error}") from error

What changed in the workflow?

The validated model and parsed JSON dictionary now remain available. The script passes both values to write_outputs() before reporting success.

  • Find the final print statements in main().
  • Locate this current output block:
    print(f"Validated {len(pack.ideas)} ideas and {len(pack.shorts)} Shorts.")
    print(f"Selected idea: {pack.selected_idea.title}")
    print("Review every field before production or publication.")

What does the current output confirm?

The current messages confirm validation and show the selected title. They do not tell the creator where the exported files were saved.

  • Replace the current output block with this expanded version:
    print(f"Validated {len(pack.ideas)} ideas and {len(pack.shorts)} Shorts.")
    print(f"Selected idea: {pack.selected_idea.title}")
    print(f"JSON: {json_path}")
    print(f"Markdown: {markdown_path}")
    print("Review every field before production or publication.")

What does the final output add?

The terminal now prints both generated paths. The final reminder keeps human approval as the last gate before production or publication.

  • Save youtube_agent.py.

✔️ Awesome, I've got everything!

Your exporter is connected. Make sure youtube_agent.py is saved before generating the first pair of files.

ⓧ I'd like to double check the full code

Compare your complete youtube_agent.py with this reference:

import argparse
import json
import os
import re
from pathlib import Path

from google import genai
from google.genai import types
from pydantic import BaseModel


MODEL_ID = "gemini-3.8-flash"


class VideoIdea(BaseModel):
    title: str
    premise: str
    hook: str
    emotional_turn: str
    originality_angle: str


class Scene(BaseModel):
    beat: str
    voiceover: str
    visual_prompt: str
    animation_prompt: str
    edit_notes: str


class LongVideo(BaseModel):
    title: str
    hook: str
    scenes: list[Scene]
    call_to_action: str


class ShortVideo(BaseModel):
    title: str
    hook: str
    voiceover: str
    visual_prompt: str
    animation_prompt: str
    edit_plan: str


class ThumbnailPlan(BaseModel):
    concept: str
    text: str
    image_prompt: str


class SeoPackage(BaseModel):
    title_options: list[str]
    description: str
    hashtags: list[str]
    keywords: list[str]
    tags: list[str]


class ReviewChecklist(BaseModel):
    family_friendly: bool
    advertiser_friendly_notes: str
    originality_notes: str
    audience_designation_review: str
    ai_disclosure_review: str
    human_approval_required: bool


class ContentPack(BaseModel):
    channel_strategy: str
    ideas: list[VideoIdea]
    selected_idea: VideoIdea
    selection_reason: str
    long_video: LongVideo
    shorts: list[ShortVideo]
    thumbnail: ThumbnailPlan
    seo: SeoPackage
    review: ReviewChecklist


def build_prompt(idea: str, language: str) -> str:
    return f"""
Create an original YouTube production package from this seed:

{idea}

Output language: {language}
Content style: family-friendly cartoon comedy for a general audience.

Requirements:
- Generate exactly five materially different story ideas.
- Select one idea using clarity, comedy, emotion, and visual potential.
- Create one cohesive long-video plan with a beginning, middle, and end.
- Create exactly three Shorts with different hooks, conflicts, and payoffs.
- Include voice-over, image prompts, animation prompts, editing notes,
  thumbnail direction, titles, description, hashtags, keywords, and tags.
- Keep every character, joke, image, and storyline original.
- Do not imitate a living creator, copyrighted character, franchise, or brand.
- Avoid profanity, sexual material, graphic violence, dangerous acts,
  discrimination, humiliation, shock bait, and manipulative distress.
- Do not claim an idea is trending unless the input supplies a source.
  This tool does not browse the web.
- Explain that family-friendly does not automatically determine whether a
  video is made for kids. Flag audience designation for human review.
- Flag realistic synthetic depictions, real-person impersonation, real-event
  alteration, realistic fake scenes, or AI-generated music for disclosure
  review. Do not make a legal conclusion.
- Require human approval before production or publication.
- Make the ideas distinct enough that they do not feel mass-produced.
""".strip()


def generate_preview(idea: str, language: str) -> str:
    client = genai.Client()
    try:
        response = client.models.generate_content(
            model=MODEL_ID,
            contents=build_prompt(idea, language),
        )
        return response.text or "No preview text was returned."
    finally:
        client.close()


def generate_content_pack(
    idea: str, language: str
) -> tuple[ContentPack, dict]:
    client = genai.Client()
    try:
        response = client.models.generate_content(
            model=MODEL_ID,
            contents=build_prompt(idea, language),
            config=types.GenerateContentConfig(
                response_mime_type="application/json",
                response_json_schema=ContentPack.model_json_schema(),
            ),
        )
    finally:
        client.close()

    if not response.text:
        raise ValueError("Gemini returned an empty response.")

    content_pack = ContentPack.model_validate_json(response.text)

    if len(content_pack.ideas) != 5:
        raise ValueError("The package must contain exactly five ideas.")
    if len(content_pack.shorts) != 3:
        raise ValueError("The package must contain exactly three Shorts.")
    if not content_pack.review.human_approval_required:
        raise ValueError("Human approval must remain required.")

    return content_pack, json.loads(response.text)


def slugify(value: str) -> str:
    slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
    return slug[:60] or "youtube-content-pack"


def render_markdown(pack: ContentPack) -> str:
    lines = [
        f"# {pack.selected_idea.title}",
        "",
        "## Channel Strategy",
        "",
        pack.channel_strategy,
        "",
        "## Five Original Ideas",
        "",
    ]

    for number, idea in enumerate(pack.ideas, start=1):
        lines.extend(
            [
                f"### {number}. {idea.title}",
                "",
                f"Premise: {idea.premise}",
                "",
                f"Hook: {idea.hook}",
                "",
                f"Emotional turn: {idea.emotional_turn}",
                "",
                f"Originality angle: {idea.originality_angle}",
                "",
            ]
        )

    lines.extend(
        [
            "## Selected Idea",
            "",
            f"Selected: {pack.selected_idea.title}",
            "",
            pack.selection_reason,
            "",
            "## Long Video",
            "",
            f"Title: {pack.long_video.title}",
            "",
            f"Hook: {pack.long_video.hook}",
            "",
        ]
    )

    for number, scene in enumerate(pack.long_video.scenes, start=1):
        lines.extend(
            [
                f"### Scene {number}: {scene.beat}",
                "",
                f"Voice-over: {scene.voiceover}",
                "",
                f"Visual prompt: {scene.visual_prompt}",
                "",
                f"Animation prompt: {scene.animation_prompt}",
                "",
                f"Edit notes: {scene.edit_notes}",
                "",
            ]
        )

    lines.extend(
        [
            f"Call to action: {pack.long_video.call_to_action}",
            "",
            "## Shorts",
            "",
        ]
    )

    for number, short in enumerate(pack.shorts, start=1):
        lines.extend(
            [
                f"### Short {number}: {short.title}",
                "",
                f"Hook: {short.hook}",
                "",
                f"Voice-over: {short.voiceover}",
                "",
                f"Visual prompt: {short.visual_prompt}",
                "",
                f"Animation prompt: {short.animation_prompt}",
                "",
                f"Edit plan: {short.edit_plan}",
                "",
            ]
        )

    lines.extend(
        [
            "## Thumbnail",
            "",
            f"Concept: {pack.thumbnail.concept}",
            "",
            f"Text: {pack.thumbnail.text}",
            "",
            f"Image prompt: {pack.thumbnail.image_prompt}",
            "",
            "## SEO Package",
            "",
            "### Title Options",
            "",
        ]
    )
    lines.extend(f"- {item}" for item in pack.seo.title_options)
    lines.extend(
        [
            "",
            "### Description",
            "",
            pack.seo.description,
            "",
            "### Hashtags",
            "",
            " ".join(pack.seo.hashtags),
            "",
            "### Keywords",
            "",
            ", ".join(pack.seo.keywords),
            "",
            "### Tags",
            "",
            ", ".join(pack.seo.tags),
            "",
            "## Human Review Checklist",
            "",
            f"- Family-friendly draft: {pack.review.family_friendly}",
            f"- Advertiser-friendly notes: {pack.review.advertiser_friendly_notes}",
            f"- Originality notes: {pack.review.originality_notes}",
            f"- Audience designation review: {pack.review.audience_designation_review}",
            f"- AI disclosure review: {pack.review.ai_disclosure_review}",
            f"- Human approval required: {pack.review.human_approval_required}",
            "",
        ]
    )
    return "\n".join(lines)


def write_outputs(pack: ContentPack, json_data: dict) -> tuple[Path, Path]:
    output_dir = Path("outputs")
    output_dir.mkdir(exist_ok=True)
    base_name = slugify(pack.selected_idea.title)
    json_path = output_dir / f"{base_name}.json"
    markdown_path = output_dir / f"{base_name}.md"

    json_path.write_text(
        json.dumps(json_data, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )
    markdown_path.write_text(render_markdown(pack), encoding="utf-8")
    return json_path, markdown_path


def main() -> None:
    parser = argparse.ArgumentParser(
        description="Generate a structured YouTube cartoon-comedy content pack."
    )
    parser.add_argument("idea", help="Original cartoon-comedy story seed")
    parser.add_argument("--language", default="English")
    parser.add_argument(
        "--preview",
        action="store_true",
        help="Print a free-form draft to expose the unstructured-output problem.",
    )
    args = parser.parse_args()

    if not os.environ.get("GEMINI_API_KEY"):
        raise SystemExit("Set GEMINI_API_KEY before running the agent.")

    if args.preview:
        print(generate_preview(args.idea, args.language))
        return

    try:
        pack, json_data = generate_content_pack(args.idea, args.language)
        json_path, markdown_path = write_outputs(pack, json_data)
    except Exception as error:
        raise SystemExit(f"Generation failed: {error}") from error

    print(f"Validated {len(pack.ideas)} ideas and {len(pack.shorts)} Shorts.")
    print(f"Selected idea: {pack.selected_idea.title}")
    print(f"JSON: {json_path}")
    print(f"Markdown: {markdown_path}")
    print("Review every field before production or publication.")


if __name__ == "__main__":
    main()

Before you run the exporter, what two file types do you expect it to create from one validated response?

  • Generate the first exported package from the story seed used throughout the project by running:
python youtube_agent.py "A lazy lion opens a gym"

What should you see?

The terminal should confirm five validated ideas and three validated Shorts. It should also print one JSON path and one Markdown path under outputs/.

That is the export loop working from generation through validation to local files. Your first production package is now ready for inspection.

Files were not created?

Check that the terminal still uses the activated virtual environment. Confirm that the current shell still has the Gemini API key available.

If generation succeeds but writing fails, compare write_outputs() with the full-code reference. Check the indentation around both write_text() calls.

help me debug the missing output files

Generate and inspect a second package

A second run tests whether the exporter handles another language without changing the file workflow. The generated files remain drafts until you review every creative field and policy note.

Before you run the final check, do you expect the outputs listing to contain matching file extensions for each exported package?

  • Generate a Hindi package and list the output directory by running:
python youtube_agent.py "A lazy lion opens a gym" --language "Hindi" && ls outputs

What should you see?

The terminal should confirm five ideas and three Shorts before showing the selected title. You should also see matching .json and .md filenames in the directory listing.

The filenames use the selected-title slug returned by the model. A title with no supported English letters uses youtube-content-pack as its fallback name.

  • Return to the file sidebar in your code editor from earlier.
  • Expand the outputs directory.
  • Open the generated .md file for the Hindi package.
  • Confirm that the Markdown brief contains the long-video plan.
  • Confirm that the brief contains three distinct Shorts.
  • Confirm that the brief contains the SEO package.
  • Confirm that the brief ends with the human-review checklist.
  • Open the matching .json file.

The JSON file should preserve the validated field structure. The Markdown file should present the same package as a production brief.

  • Review the originality notes before approving the package.
  • Review the audience designation notes before approving the package.
  • Review the advertiser-friendly notes before approving the package.
  • Review the AI disclosure notes before approving the package.

Why approval stays manual

A schema can prove that every required field exists. It cannot prove that a joke is original or that an audience designation is appropriate.

The agent prepares assets for a YouTube workflow. A person still decides whether the package is suitable for production or publication.

You have closed the gap between validated data and a usable creator handoff. Each idea can now produce a readable brief plus structured source data while keeping publication under human control.

Secret mission

Lock In Your Channel Voice

Create a reusable brand brief for your cartoon-comedy channel. Test it with two unrelated ideas to see whether your agent preserves a recognizable voice without recycling the same story.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the workspace ready, pause access for later, or remove the project artifacts. The local files have no ongoing cost.

Resources you used:

  • The .venv Python virtual environment containing google-genai==2.33.0 plus pydantic==2.14.1.
  • The pinned dependency file named requirements.txt.
  • The completed agent in youtube_agent.py.
  • The outputs/ directory containing your generated .json packages. It also contains matching .md production briefs.
  • The reusable channel rules in brand_bible.txt.
  • The comparison findings in brand_consistency_notes.txt.
  • The GEMINI_API_KEY environment variable exported in the current shell.
  • The API key created in Google AI Studio for Gemini API access.

Keep everything running

No action needed. Choose this if you want to generate more content packages from the same workspace.

  • Your virtual environment keeps the pinned Python packages ready.
  • Your completed agent remains available in youtube_agent.py.
  • Your generated packages remain available in outputs/.
  • Your channel rules remain available in brand_bible.txt.
  • Your comparison findings remain available in brand_consistency_notes.txt.
  • Your API key remains available in Google AI Studio. Future Gemini API calls remain subject to current free-tier limits.
  • Human approval remains required before you use any package for production or publication.

Pause - I'll come back to this later

Remove API access from the current shell while keeping every local artifact for your next session.

  • Clear the current shell credential by running this command:
unset GEMINI_API_KEY

What does this command do?

The command removes the environment variable from the current shell. Your API key remains available in Google AI Studio for later use.

  • Keep every local artifact listed above.
  • Restore the workspace when you return by running these commands:
source .venv/bin/activate
export GEMINI_API_KEY="YOUR_API_KEY"

How does this restore the workspace?

  • The first command activates the existing Python virtual environment.
  • The second command makes your API key available to the agent in the new shell session.

Delete - I don't want to use this again

Remove the shell credential, the Google AI Studio API key, and the named local artifacts. Follow the cleanup in this order.

Clear the current shell credential
  • Remove GEMINI_API_KEY from the activated Linux shell by running this command:
unset GEMINI_API_KEY

What changes in this shell?

The current shell stops passing the API key to the agent. This clears the credential before you remove the remaining project artifacts.

Delete the Google AI Studio API key

Deleting this credential blocks future requests that depend on it. Your existing local packages remain intact until you remove them separately.

  • Return to the API key page in Google AI Studio that you used in Step 1.
  • Select the project API key you created.
  • Use the delete control beside that key.
  • Confirm the deletion if Google AI Studio asks for confirmation.

You should see the deleted key disappear from the API key list.

Delete the local project artifacts

Check the file list first

This cleanup is deliberately final. The command targets only the named artifacts from this project.

  • Return to the terminal in the activated Linux workspace.
  • Remove the virtual environment plus every named project artifact by running this command:
rm -rf .venv requirements.txt youtube_agent.py outputs brand_bible.txt brand_consistency_notes.txt

What does this command remove?

The command deletes the installed project environment, the source files, the generated packages, the brand brief, and the consistency notes. Other files in the current workspace remain untouched.

  • Verify that the named artifacts are gone by running this command:
ls

What should you see?

The output should no longer list requirements.txt, youtube_agent.py, outputs, brand_bible.txt, or brand_consistency_notes.txt.

Nice Work!

Nice Work!

You did it! You've built a Linux command-line agent powered by the Gemini API. It transforms one cartoon-comedy seed into a validated YouTube production package for human review.

You've learned how to:

  • Generate five original ideas from one cartoon-comedy seed. Expand the selected concept into one long-video plan plus three distinct Shorts.
  • Use Pydantic validation to enforce a predictable content schema. Check exact item counts in the application. Preserve mandatory human approval before production or publication.
  • Export production-ready packages as readable Markdown files. Save matching JSON files for downstream tools.
  • Secret Mission: Created a reusable brand brief for the channel voice. Tested it across two unrelated ideas to expose consistency plus drift. Recorded the next prompt rule to tighten.

Ready to quiz yourself?